Claude Media
Claude CodeでTerraformのコードを書く手順 — plan差分の検証まで

Claude CodeでTerraformのコードを書く手順 — plan差分の検証まで

Claude CodeにTerraformの構成を書かせるとき、CLAUDE.mdの検証手順・fmtフック・applyを止める権限設定で、plan差分を人が確認する形に整える手順です。

Claude CodeにTerraformのコードを書かせるときの要点は、HCLの書き方より「書いたあとに何を通すか」を先に決めておくことです。terraform fmtとterraform validateで形と整合を機械的に確かめ、terraform planの差分を人が読み、applyはClaudeに実行させない。この分担をCLAUDE.md・フック・権限設定に分けて置くと、コードの生成が速くなっても本番への反映は人の手元に残ります。

先に決める検証ループ

Terraformは書いた構成がそのまま実インフラを変える道具です。そのためClaudeに任せる範囲は、次の4段に分けると扱いやすくなります。

4段

Terraformの検証ループ

  1. 1

    整形: terraform fmt

    標準の書式に揃っているかを見ます。フックが編集のたびに自動で実行します。

  2. 2

    検証: terraform validate

    構文と内部の整合(属性名・型)を見ます。Claudeが編集のたびに実行します。

  3. 3

    差分: terraform plan -out=tfplan

    何が作られ、変わり、消えるかを出します。Claudeが実行し、人が読みます。

  4. 4

    反映: terraform apply

    実インフラが変わる段です。人だけが実行します。

terraform validateは、構成が構文として正しく内部で矛盾していないかを、変数や既存のstateと無関係に確かめるコマンドです。リモートのstateやプロバイダーAPIは検証しません。HashiCorpは、エディタの保存後チェックやCIのテスト手順のように自動で回しても安全だと説明しています。Claudeが編集を重ねる作業と相性がよい理由はここにあります。

一方で、validateが通っても「意図した変更になっているか」は分かりません。属性名が正しくても、消したくないリソースが削除対象に入っていることはあります。それを見つけられるのはplanだけです。

CLAUDE.mdにTerraformの作業手順を書く

CLAUDE.mdはセッション開始時に毎回コンテキストへ読み込まれる指示ファイルで、公式は1ファイル200行未満を目安にしています。また、指示は「検証できるほど具体的」に書くほうが守られやすいとされています。「Terraformを正しく書く」ではなく、コマンド名まで書きます。

# Terraform 作業ルール
 
## 構成
- ルートモジュールは `infra/` 配下。環境ごとの値は `envs/<env>.tfvars`
- 共有モジュールは `modules/` に置き、ルートから `source = "../modules/<name>"` で呼ぶ
 
## 編集のたびに実行する
1. `terraform fmt -recursive`
2. `terraform validate`(初回だけ通常の `terraform init` を先に実行し、以降の検証ループでは `terraform init -backend=false` を使う)
3. エラーが出たら原因を読んで直し、通るまで繰り返す
 
## 変更を提案するとき
- `terraform plan -out=tfplan` を実行し、add / change / destroy の件数を報告する
- destroy と replace を含む場合は、対象リソースのアドレスを一覧で示す
 
## 禁止
- `terraform apply` と `terraform destroy` は実行しない
- `*.tfstate` と `*.tfvars` の中身を出力しない・コミットしない
- `-target` は復旧作業のときだけ使い、日常の変更には使わない

terraform init -backend=falseを挟むのは、validateには初期化済みの作業ディレクトリが要るためです。この指定なら、設定済みのbackendに触れずにプロバイダーとモジュールだけを取得でき、検証のループを回せます。ただしHashiCorpは、このフラグを、そのバックエンド向けに初期化済みの作業ディレクトリで使うよう案内しています。planに使う初期化は、通常のterraform initで行います。

-targetを禁止側に置くのは、HashiCorpが「復旧やTerraformの制約の回避といった例外的な場面でだけ使う」もので、日常の操作には推奨しないと明記しているためです。「このリソースだけ確認したい」という場面で-targetが使われやすいため、ルールとして先に封じておきます。

構成が大きく、環境ごとに指示を変えたい場合は、.claude/rules/にファイルを分ける方法もあります。pathsフロントマターで対象を絞ると、該当ファイルを読んだときだけ読み込まれます。

---
paths:
  - "**/*.tf"
  - "**/*.tfvars"
---
 
# HCL の書き方
- 変数には `description` と `type` を必ず付ける
- 認証情報などの値は `sensitive = true` を付けた変数で受ける

編集のたびにfmtを走らせるフック

整形はClaudeに頼むより、フックで機械的に走らせたほうが確実です。公式ガイドにはPrettierを対象にしたPostToolUseフックの例があり、Edit|Writeをmatcherにして編集後のファイルへコマンドを流す形です。同じ骨格をTerraform向けに置き換えると次のようになります(公式のPrettier例をもとにした形で、公式がTerraform用に示しているものではありません)。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "f=$(jq -r '.tool_input.file_path'); case \"$f\" in *.tf|*.tfvars) terraform fmt \"$f\" ;; esac"
          }
        ]
      }
    ]
  }
}

terraform fmtは、対象に特定のファイルを渡せます。.tfと.tfvarsだけを通すcase文を入れたのは、READMEや設定ファイルを編集したときにfmtが空振りしないようにするためです。-checkを付けると、ファイルを書き換えずに整形が必要なファイルの一覧と終了コードだけを返すので、フックではなくCIの確認に向いています。

設定ファイルに入れる前に、フックのコマンドだけを単体で試せます。terraformを引数の表示だけする代用スクリプトに差し替え、フックが受け取るのと同じ形のJSONを標準入力に流します。

H='f=$(jq -r ".tool_input.file_path"); case "$f" in *.tf|*.tfvars) terraform fmt "$f" ;; esac'
echo '{"tool_input":{"file_path":"infra/main.tf"}}' | sh -c "$H"
echo '{"tool_input":{"file_path":"README.md"}}' | sh -c "$H"
echo '{"tool_input":{"file_path":"envs/prod.tfvars"}}' | sh -c "$H"

出力はterraform fmt infra/main.tfとterraform fmt envs/prod.tfvarsの2行だけで、README.mdでは何も実行されません。

なおfmtは、HashiCorpが「意図的に意見の強いコマンド」としており、カスタマイズの選択肢がありません。書式の議論をClaudeとの間で始めなくて済む、という点でもフック向きです。

plan差分を読むところまでをClaudeに任せる

構成を書いたら、planを保存形式で実行させます。

terraform plan -out=tfplan
terraform show tfplan

-outで保存したplanは、あとからterraform apply tfplanに渡して、そのまま実行できます。この2段の流れは、HashiCorpが主にCIなどの自動化での利用を想定しているものです。-outを付けないplanは「投機的プラン」で、適用する意図のない差分の確認用と位置づけられています。

保存ファイル名に.tfの拡張子は付けません。Terraformが設定ファイルとして解釈し、以降のコマンドが構文エラーになるためで、慣例はtfplanです。

Claudeへの依頼は、次のように「変更内容の報告」を求める形にします。

infra/network の変更を plan して。
add / change / destroy の件数と、destroy と replace の対象アドレスを表にして報告して。
apply はしないで、差分の読み方に迷う箇所があればそのまま質問して。

差分が空かどうかだけを機械的に知りたいときは、-detailed-exitcodeが使えます。終了コードが0なら差分なし、1ならエラー、2なら差分ありです。Claudeに「2が返ったら変更内容を要約する」と指示しておくと、スクリプトや後述の権限設定とも組み合わせられます。

destroyとreplaceをjqで拾う

保存したplanは、terraform show -jsonでJSONにできます。出力のresource_changes配列には、リソースごとにaddressとchange.actionsが入ります。actionsが["delete"]なら削除、["delete","create"]なら作り直し(replace)です。作成を先に行う置き換えは、["create","delete"]の順で入ります。jqのindex("delete")は、どちらの順でも拾います。人が画面のplanから拾っていた「消えるもの」の一覧を、jqで機械的に出せます。

terraform show -json tfplan | jq -r '
  .resource_changes[]
  | select(.change.actions | index("delete"))
  | "\(.change.actions | join("+"))\t\(.address)"'

このコマンドの動きは、同じ構造の最小のJSONで試せます。次のサンプルには、変更なし・作り直し・削除・更新・作成を1件ずつ入れてあります。

jq -r '.resource_changes[] | select(.change.actions | index("delete"))
  | "\(.change.actions | join("+"))\t\(.address)"' plan.json
delete+create	aws_instance.web[0]
delete	aws_db_instance.main

no-op・update・createの3件は出力に出ません。件数の集計はgroup_byで書けます。

jq -r '[.resource_changes[].change.actions | join("+")]
  | group_by(.) | map("\(.[0])=\(length)") | .[]' plan.json
create=1
delete=1
delete+create=1
no-op=1
update=1

Claudeにこの形で報告させるなら、権限側の許可も要ります。パイプの|はコマンドの区切りとして扱われ、各部分が独立に照合されるため、Bash(terraform show *)のほかにBash(jq *)もallowに置きます。

terraform show -jsonは、stateの機密値を平文で出します。jqでaddressとactionsだけに絞れば、機密値がClaudeの読む出力に混ざりません。ただしBash(terraform show *)を許可すると、terraform show -json tfplanをそのまま実行することもできます。HashiCorpは、stateの機密値がshowで平文表示されると説明しています。絞り込みは設定では強制されないので、jqで絞る手順をCLAUDE.mdに書いておきます。

applyを実行させない権限設定

CLAUDE.mdの禁止事項は指示であって、強制力はありません。強制したいなら、権限設定のdenyルールを使います。公式のルール評価は「deny、ask、allowの順で最初に一致したものが結果を決める」もので、denyに書いた内容は、より狭いallowが後ろにあっても覆りません。

{
  "permissions": {
    "allow": [
      "Bash(terraform fmt *)",
      "Bash(terraform validate *)",
      "Bash(terraform init -backend=false *)",
      "Bash(terraform plan *)",
      "Bash(terraform show *)"
    ],
    "deny": [
      "Bash(terraform apply *)",
      "Bash(terraform destroy *)",
      "Bash(terraform state *)"
    ]
  }
}

書き方の要点は、*をサブコマンドの後ろに置くことです。Bash(terraform plan *)はterraform planで始まるコマンドだけを許可し、Bash(terraform *)にするとapplyまで通ってしまいます。

ただしBashのルールが照合するのは、Claudeが書いたコマンドの文字列そのものです。公式の例では、Bash(git push *)はgit push origin mainを止めてもgit -C . push origin mainは止めません。terraformにあてはめると、terraform -chdir=infra applyが同じ型です。さらに-chdir付きの呼び出しはallow側にも一致しないため、planまで毎回確認を求められるようになります。

denyをすり抜ける書き方はPreToolUseフックで塞ぐ

コマンド全文を自前のロジックで検査したいときは、PreToolUseフックが向いています。Bashの実行前に呼ばれ、終了コード2で止められ、止めた理由は標準エラーに書きます。.claude/hooks/block-terraform-apply.shを作ります。

#!/bin/sh
cmd=$(jq -r '.tool_input.command')
if printf '%s' "$cmd" | grep -Eq '(^|[[:space:];&|/"'"'"'])terraform[[:space:]].*(apply|destroy)([[:space:]"'"'"';&|]|$)'; then
  echo "terraform apply と destroy は実行できません。plan までで止めてください" >&2
  exit 2
fi

.claude/settings.jsonには、matcherをBashにして登録します。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": ".claude/hooks/block-terraform-apply.sh" }
        ]
      }
    ]
  }
}

スクリプトの判定は、標準入力にJSONを流せば単体で試せます。

for c in "terraform plan -out=tfplan" "terraform apply tfplan" \
  "terraform -chdir=infra apply" "/usr/local/bin/terraform apply" \
  "sh -c 'terraform destroy'" "terraform show -json tfplan | jq ."; do
  jq -n --arg c "$c" '{tool_input:{command:$c}}' | .claude/hooks/block-terraform-apply.sh 2>/dev/null
  echo "$c => exit $?"
done
terraform plan -out=tfplan => exit 0
terraform apply tfplan => exit 2
terraform -chdir=infra apply => exit 2
/usr/local/bin/terraform apply => exit 2
sh -c 'terraform destroy' => exit 2
terraform show -json tfplan | jq . => exit 0

terraform plan -var 'mode=apply'のように、引数の値にapplyが入るplanも止めてしまいます。誤って止まる側に倒した判定なので、困る場合は正規表現を狭めます。

くらべる

denyルールとフックの守備範囲

権限設定

denyルール

Claudeが普通に書く形を止めます。applyのサブコマンドが直後に来る呼び出しが対象で、設定はsettings.jsonに数行で済みます。

自前の検査

PreToolUseフック

コマンド全文を読んで判定します。-chdir付き、絶対パス、sh -cの中の呼び出しまで拾えます。スクリプトは自分で保守します。

フックもコマンドの文字列を見る仕組みであることは変わらず、t=terraform; $t applyのように文字列を組み立てる呼び出しは見逃します。そこで、実行環境そのものにapplyできる資格情報を渡さない、という守り方を最後の砦にします。Claudeを動かすシェルにはplanに必要な読み取り権限だけを持たせ、変更権限を持つ認証はCIのパイプラインに置きます。

terraformがstateやキャッシュを作業ディレクトリの外へ書く構成では、サンドボックスの書き込み範囲の調整が必要になることがあります。手順はallowWrite/denyWriteでkubectl・terraformの書き込み範囲を調整するにまとまっています。

stateとplanファイルの取り扱い

Terraformのstateとplanファイルには、リソースの属性やメタデータが詳しく入ります。初期パスワードやAPIトークンのような機密値が入ることもあります。ローカルで開発するとstateは平文のファイルとして置かれるため、HashiCorpはGitの管理から外すよう案内しています。

sensitive = trueは、CLIの出力とHCP Terraformの画面から値を隠すだけです。stateとplanファイルの中には値が保存され、terraform outputに-jsonや-rawを付けると平文で表示されます。この点は、Claudeに出力を読ませる運用で見落としやすい部分です。

-outで保存したplanファイルも同じ扱いです。画面上で値が伏せられていても、ファイルには値がそのまま保存されます。planファイルは潜在的に機密を含む成果物として扱い、コミットしない、チャットに貼らない、といったルールをCLAUDE.mdの禁止項目に含めておきます。

秘匿したい値そのものをstateに残したくない場合は、Terraformの一時的な値(ephemeral)や書き込み専用の引数を使う方法があります。これらは値をplanにもstateにも保存しない仕組みで、機密値をHCLに渡す設計を、Claudeに書かせる前の段階で決めておくと手戻りが減ります。

生成物の検査を増やす

validateとplanの先に、terraform testがあります。テストファイルは.tftest.hclまたは.tftest.jsonという拡張子で、モジュールの更新が既存の挙動を壊さないかを、短命なリソースに対して確かめます。v1.7.0以降はプロバイダーの返す値をモックにでき、実インフラを作らないテストも書けます。

Claudeにモジュールを書かせるときは、「変数の検証ルールを1つ足すたびにテストも1本足す」といった依頼にしておくと、生成コードの意図が実行可能な形で残ります。テストだけ通すハードコードを防ぐプロンプトの考え方は、.tftest.hclでも同じように当てはまります。

よくあるつまずき

validateが通ったのに、planで初めて失敗する

validateは構文と内部の整合だけを見ます。存在しないAMIやリージョンの制約のような、プロバイダーAPIを介して分かる誤りは、planまで実行しないと出ません。「validateが通った」を完了の合図にしない、とCLAUDE.mdに書いておきます。

保存したplanファイルを.tfで終わる名前にした

先ほどのとおり、構成ファイルとして読み込まれて次のコマンドが失敗します。名前はtfplanか.tfplanのような別の拡張子にします。

一部のリソースだけをplanしたくて-targetが混ざる

-targetで一部だけを対象にすると、依存先の変更を見逃したplanになります。Claudeが「確認のため」に使い始めたら、ルールの読み込みを疑い、/contextでCLAUDE.mdが読まれているかを確かめます。

まとめ

どこまで強制するかは、CLAUDE.mdの指示・denyルール・フック・資格情報の分離のうち、止めたい操作の影響の大きさに見合う層を選びます。applyだけは、最後の層である資格情報の分離まで含めて人の側に置く構成が確実です。

同じ考え方は、KubernetesマニフェストやAnsibleのPlaybookにも通用します。Terraform側のツール連携としては、Terraform MCPサーバーを経由してClaudeにIaCコードを書かせる方法もあります。サーバーレスでは、AWS SAMのdeployを承認制にする手順も同じ構造です。

この記事を共有:XはてブLinkedIn