Terraform Cloud の代わりに S3 ネイティブロック + GitHub Actions OIDC を試してみた話
はじめに
これまで Terraform Cloud を使って state 管理と CI/CD を回してきました。が、最近マイクロサービス化を検討するなかで、こんな悩みが出てきました。
- コスト問題: マイクロサービスごとに workspace を切っていくと、リソース数に応じて Terraform Cloud のコストがじわじわ効いてきます
- デプロイ経路がバラバラ問題: アプリは AWS SAM、インフラは Terraform Cloud。リリースのたびに 2 つの仕組みを意識するのが地味につらいです
そこで、
- state は AWS S3 (Terraform v1.10 から入ったネイティブロック機能) で管理
- 認証は GitHub Actions OIDC で AWS にフェデレーション(アクセスキー不要)
- デプロイ経路は GitHub Actions に統一
という構成を試してみたら、わりとシンプルかつ快適に動いてくれたので、検証の流れをまとめます。
詰まったポイントもそこそこあるので、同じ構成を考えてる方の参考になればうれしいです。
全体構成
ざっくり図にするとこんな感じです。
+--------------------+
| GitHub Repository |
| (terraform code) |
+---------+----------+
|
push / PR v
+---------+----------+
| GitHub Actions |
| (plan / apply) |
+---------+----------+
|
OIDC v AssumeRole
+---------+----------+
| IAM Role |
| github-actions-tf |
+---------+----------+
|
+---------------+---------------+
| |
v v
+-----------------+ +-----------------+
| S3 (tfstate) | | AWS Resources |
| + native lock | | (env per key) |
+-----------------+ +-----------------+
ディレクトリ構成はこう切りました。
.
├── .github/workflows/terraform.yml
├── bootstrap/ # state 管理リソース自身を作る (state はローカル管理)
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── terraform/
│ └── environments/
│ ├── prod/
│ ├── stg/
│ └── dev/
└── .terraform-version
ポイントは bootstrap だけ別ディレクトリで、しかも state をローカルで持つ ところです。
考えてみれば当たり前で、「state 保管用の S3 バケット」を作る Terraform が、その作りたいバケットを backend に指定するのは無理ですよね。鶏と卵問題なので、bootstrap だけは特別扱いです。
1. bootstrap で土台を作る
bootstrap で作るのは以下のリソースです。
-
S3 バケット (tfstate 用)
- バージョニング有効 / SSE-S3 暗号化 / Public Access Block / 90 日で旧バージョン削除
-
IAM Role:
github-actions-terraform- GitHub OIDC で AssumeRole される
- IAM Policy 2 つ: tfstate 用と、実リソース管理用
OIDC Provider もここで作ろうとしたんですが、ここで 1 回目のつまずき に当たりました。詳細は後述します。
backend は意図的に書かず、ローカルの terraform.tfstate で運用します。
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
各環境用の state は、すべて 1 つの S3 バケットに「環境名/terraform.tfstate」のキーで突っ込む設計にしました。
s3://<bucket>/dev/terraform.tfstate
s3://<bucket>/stg/terraform.tfstate
s3://<bucket>/prod/terraform.tfstate
ローカルから 1 回だけ apply します。
$ aws sts get-caller-identity --profile <my-sso-profile>
{
"Account": "xxxxxxxxxxxx",
"Arn": "arn:aws:sts::xxxxxxxxxxxx:assumed-role/.../<your-user>"
}
$ cd bootstrap/
$ terraform init
$ terraform plan -out=tfplan
...
Plan: 11 to add, 0 to change, 0 to destroy.
$ terraform apply tfplan
...
Apply complete! Resources: 10 added, 0 changed, 0 destroyed.
(11 のうち 10 しか作られていないのは、後述の OIDC Provider 問題で resource → data に切り替えたためです)
2. 各環境の backend を S3 に向ける
bootstrap で作った S3 バケットを backend に指定します。Terraform v1.10 から入った ネイティブロック を使うので、DynamoDB の lock table はいりません。use_lockfile = true が決め手です。
terraform {
required_version = ">= 1.10"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
backend "s3" {
bucket = "<tfstate-bucket-name>"
key = "dev/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true # ← これが v1.10 の新機能
}
}
これだけで、plan / apply の最中は S3 上に dev/terraform.tfstate.tflock という小さなファイルが作成され、終了すると消えます。DynamoDB が不要になったので、運用するリソースが純粋に 1 つ減りました。地味にうれしいです。
検証用に、各環境で「タグ確認用の S3 バケット」を 1 個ずつ作るシンプルな構成にしておきます。
resource "aws_s3_bucket" "sample" {
bucket = "learn-tf-sample-${var.environment}"
tags = {
Name = "learn-tf-sample-${var.environment}"
Purpose = "smoke-test for tfstate backend and CI/CD"
}
}
3. GitHub Actions のワークフロー
ここは少し長くなりますが、ポイントになる部分なので順番にコードを見ていきます。
3-1. トリガー
on:
pull_request:
paths:
- "terraform/**"
- ".github/workflows/terraform.yml"
push:
branches:
- main
paths:
- "terraform/**"
- ".github/workflows/terraform.yml"
workflow_dispatch:
inputs:
environment:
description: "Target environment"
type: choice
options: [dev, stg, prod]
action:
description: "Action to perform"
type: choice
options: [plan, apply]
-
PR:
planのみ実行して PR コメントに差分を投稿します -
main への push:
plan+ 承認待ち +applyを 3 環境分まわします -
手動 (
workflow_dispatch): 環境と action を選んで実行できます
paths で terraform/** と workflow ファイル自身に絞っているので、README や bootstrap だけ変えたときには走りません。CI 時間とコストの無駄打ちを避けるための小さな工夫です。
3-2. 権限と環境変数
permissions:
id-token: write # OIDC で JWT を発行するために必須
contents: read # ソースの fetch
pull-requests: write # plan の結果を PR コメントとして書く
env:
AWS_REGION: ap-northeast-1
AWS_ROLE_ARN: arn:aws:iam::xxxxxxxxxxxx:role/github-actions-terraform
id-token: write が抜けると OIDC AssumeRole が失敗するので、ここは忘れないようにしたいポイントです。AWS_ROLE_ARN は bootstrap で作った IAM Role を指定します。
3-3. plan ジョブ(ジョブ定義)
plan:
name: Plan (${{ matrix.environment }})
if: github.event_name != 'workflow_dispatch' || github.event.inputs.action == 'plan'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
environment: ${{ fromJSON(github.event_name == 'workflow_dispatch' && format('["{0}"]', github.event.inputs.environment) || '["dev","stg","prod"]') }}
defaults:
run:
working-directory: terraform/environments/${{ matrix.environment }}
-
matrix で 3 環境を並列実行:
fail-fast: falseにしてあるので、どこか 1 つコケても他の環境はそのまま走ります。「全環境の差分を一度に見たい」という用途に合わせています。 -
workflow_dispatchのときは指定 1 環境のみ: 三項演算子で配列をその場で組み立てるという、ちょっとしたテクニックです。 -
defaults.run.working-directory: 各ステップで毎回cd terraform/environments/...を書かなくて済むので、ステップが見通しやすくなります。
3-4. plan ジョブ(ステップ)
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Read .terraform-version
id: tfversion
run: echo "version=$(cat .terraform-version)" >> "$GITHUB_OUTPUT"
working-directory: .
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ steps.tfversion.outputs.version }}
terraform_wrapper: true
- name: Terraform fmt
id: fmt
run: terraform fmt -check -recursive
working-directory: .
- name: Terraform init
id: init
run: terraform init -input=false
- name: Terraform validate
id: validate
run: terraform validate -no-color
- name: Terraform plan
id: plan
run: terraform plan -no-color -input=false -out=tfplan
continue-on-error: true
ポイントは 3 つ:
-
.terraform-versionを読んで Terraform のバージョンを動的に決めている:hashicorp/setup-terraformはterraform_versionを省略するとlatestを取りに行くので、リポジトリの.terraform-versionをcatした値を渡すようにしています。これでローカル(tfenv)と CI のバージョンを一元管理できます。 -
terraform fmt -check -recursiveはworking-directory: .: フォーマットチェックだけリポジトリ全体に対して走らせたいので、defaultsで設定したディレクトリを上書きしています。 -
continue-on-error: trueを plan に付けている: plan が失敗してもジョブをすぐ止めず、次のステップ(PR コメント)で原因を投稿してから止めたい、という意図です。最後に「plan が失敗したらジョブも失敗扱い」のステップを別途置いて締めています。
3-5. plan の結果を PR コメントに投稿する
actions/github-script を使って、plan 結果を PR コメントとして書きます。同じ環境のコメントが既にあれば更新、なければ新規作成という挙動なので、何度 push しても 3 環境分のコメントが 1 つずつにまとまります。
- name: Comment plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
env:
PLAN_STDOUT: "${{ steps.plan.outputs.stdout }}"
with:
script: |
const env = "${{ matrix.environment }}";
const fmt = "${{ steps.fmt.outcome }}";
const init = "${{ steps.init.outcome }}";
const validate = "${{ steps.validate.outcome }}";
const plan = "${{ steps.plan.outcome }}";
const planOutput = (process.env.PLAN_STDOUT || "").slice(0, 60000);
const body = `### Terraform Plan: \`${env}\`
| Step | Result |
| --- | --- |
| fmt | \`${fmt}\` |
| init | \`${init}\` |
| validate | \`${validate}\` |
| plan | \`${plan}\` |
<details><summary>Show plan</summary>
\`\`\`hcl
${planOutput}
\`\`\`
</details>
*Triggered by @${{ github.actor }} on \`${{ github.sha }}\`*`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const marker = `### Terraform Plan: \`${env}\``;
const existing = comments.find(c => c.body && c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: context.issue.number, body,
});
}
- name: Fail if plan errored
if: steps.plan.outcome == 'failure'
run: exit 1
地味だけど効くポイント:
-
PLAN_STDOUTをenv経由で渡している: plan 出力にはバッククォートやドル記号が混じることがあって、テンプレート文字列に直接埋め込むとエスケープがバグります。process.env.PLAN_STDOUTで受け取るとそういう事故が起きないので安全です。 -
60000文字で slice: GitHub コメントの上限が約 65,536 文字なので、それを超えないようにカットしておきます。 -
マーカー文字列で既存コメントを検索:
### Terraform Plan: \${env}`` を先頭に置くことで、環境ごとに 1 つのコメントに集約できます。PR を何度 push し直してもコメントが増殖しません。
3-6. apply ジョブ
apply:
name: Apply (${{ matrix.environment }})
needs: plan
if: |
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply')
runs-on: ubuntu-latest
strategy:
fail-fast: true
max-parallel: 1
matrix:
environment: ${{ fromJSON(...) }}
environment: ${{ matrix.environment }} # ← ここで Required reviewers 待ちになる
defaults:
run:
working-directory: terraform/environments/${{ matrix.environment }}
このジョブの設計上のキモは:
-
environment: ${{ matrix.environment }}: GitHub Environments の保護ルールがここで効きます。各環境のジョブが開始する前に「Required reviewers の承認待ち」状態になります。 -
fail-fast: true+max-parallel: 1: dev → stg → prod の順に直列で apply。途中でコケたら以降は止まります。「本番に問題のある変更を持ち込まない」ためのガードです。 -
needs: plan: plan ジョブが全部成功してから apply ジョブを起動。
ステップは plan よりシンプルです。
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ env.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Read .terraform-version
id: tfversion
run: echo "version=$(cat .terraform-version)" >> "$GITHUB_OUTPUT"
working-directory: .
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ steps.tfversion.outputs.version }}
terraform_wrapper: false
- name: Terraform init
run: terraform init -input=false
# tfplan を artifact 経由で受け渡さず、apply ジョブ内で再 plan + apply
# (理由は後述「つまずき (3)」参照)
- name: Terraform apply
run: terraform apply -input=false -auto-approve
承認 → 起動 → 数十秒で完了、という流れで、Plan ジョブと Apply ジョブの間に stale エラーが入る余地をなくしています(詳しい経緯は後述します)。
3-7. GitHub Environments の保護ルール
apply 直前に承認待ちで止まるのは、workflow 側ではなく GitHub Environments の機能で実現しています。GitHub の Web UI から、各環境ごとに設定します。
Settings → Environments → New environment (dev / stg / prod)
- Required reviewers: 自分(または他のメンバー)
- Deployment branches: Selected branches → main のみ
これでマージしたあとも「reviewer が承認ボタンを押すまで apply は走らない」状態になります。Terraform 側で何かをする必要がなく、GitHub の機能だけで承認フローが組めるのは楽です。
4. ここからつまずきポイント
ここまで書くとサラッと動きそうですが、現実には 3 回くらいコケました。よくあるやつから、ハマりやすいやつまで、共有しておきます。
つまずき (1): OIDC Provider はすでに存在していた
bootstrap で
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [...]
}
と書いて apply したら、こうなりました。
Error: creating IAM OIDC Provider: EntityAlreadyExists:
Provider with url https://token.actions.githubusercontent.com already exists.
これは AWS アカウントごとに 1 つしか作れない共有リソースなんですよね。他のプロジェクトで先に作っていたようです。
対応: resource で作るのではなく、data で参照する形に変えました。
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
# 参照側
data "aws_iam_policy_document" "github_actions_assume_role" {
statement {
...
principals {
type = "Federated"
identifiers = [data.aws_iam_openid_connect_provider.github.arn]
}
...
}
}
こうしておくと、bootstrap を destroy しても OIDC Provider 自体は消えないので、他プロジェクトに影響を与えません。共有リソースは「所有しない」設計、大事です。
つまずき (2): S3 の IAM アクション名のトラップ
最小権限で行くぞ、と意気込んで以下のように書きました。
actions = [
"s3:CreateBucket",
"s3:DeleteBucket",
"s3:GetBucket*",
"s3:PutBucket*",
"s3:ListBucket*",
...
]
しかしバケットを作って apply するとこうなりました。
Error: reading S3 Bucket (learn-tf-sample-dev) accelerate configuration:
AccessDenied: User: arn:aws:sts::xxxxxxxxxxxx:assumed-role/github-actions-terraform/GitHubActions
is not authorized to perform: s3:GetAccelerateConfiguration on resource: "arn:aws:s3:::learn-tf-sample-dev"
s3:GetAccelerateConfiguration ... Bucket がついていない S3 IAM アクションがあるんです。
これに限らず、S3 の IAM アクション名は地味に統一感がなくて、Get/PutBucket* ワイルドカードでは取りこぼします。
対応: アクションを 1 個 1 個列挙するのは現実的じゃないので、ARN を厳密に絞ったうえで s3:* を許可 に変更しました。
statement {
sid = "ManagePrefixedBuckets"
effect = "Allow"
actions = ["s3:*"]
resources = [
"arn:aws:s3:::learn-tf-sample-*",
"arn:aws:s3:::learn-tf-sample-*/*",
]
}
「s3:* って強すぎない?」と思うかもしれませんが、リソース ARN を learn-tf-sample-* プレフィックスに固定しているので、このバケット以外には何も触れません。これは十分に 最小権限 だと思っています(action だけが「最小」なのではなく、resource × action の組み合わせで考えるのがミソ)。
つまずき (3): "Saved plan is stale" 連発問題
IAM ポリシーを直して、これでいけるだろうと思って main にマージ → 承認 → apply、で、また失敗しました。今度はこれです。
Error: Saved plan is stale
The given plan file can no longer be applied because the state was changed
by another operation after the plan was created.
最初は「state が壊れたのか?」と思って state ファイルを覗いたんですが、
$ aws s3 cp s3://<tfstate-bucket>/dev/terraform.tfstate /tmp/dev.tfstate
$ jq '.serial, .resources' /tmp/dev.tfstate
2
[]
state は serial=2 で空でした。原因は「state にゴーストレコードが残っていた」と「ワークフローの構造そのもの」の 2 つです。
ゴーストレコード問題
最初の apply 失敗(つまずき 2)で、実は S3 バケットの作成自体は成功していて、その後の状態確認で IAM がコケた、というパターンだったんです。Terraform はバケットを作った時点で state に記録しています。
私がそのあと手動でバケットを aws s3 rb してしまったので、「state には記録あり、実体なし」のゴーストレコードができていました。
対応はシンプルです:
$ terraform state rm aws_s3_bucket.sample
Removed aws_s3_bucket.sample
Successfully removed 1 resource instance(s).
state からだけ削除して、次回 plan で「リソース未作成」として正しく扱えるようにします。
ワークフローの構造問題
ゴーストレコードを直してもまだ stale が出ました。これがなかなか厄介でした。
元のワークフローは
-
Plan ジョブ:
terraform plan -out=tfplan→ artifact に upload -
Apply ジョブ (承認後): artifact から download →
terraform apply tfplan
という、よくある「plan を artifact 経由で受け渡す」構造でした。
これが落とし穴で、Plan ジョブと Apply ジョブが別 runner で動くため、Terraform が tfplan に埋め込んだ state スナップショットのハッシュと、Apply ジョブから見た現在の state のハッシュが微妙にずれることがあるみたいなんですよね。
特に native lock を使っていると、plan / apply のたびに lock の取得・解放が走り、その影響もありそうです。承認待ちの時間が長いほど、stale になりやすい印象でした。
対応: artifact 経由で plan を渡すのをやめて、Apply ジョブ内で再度 plan + apply を一気に実行するように変更しました。
- name: Terraform apply
# 旧: terraform apply -input=false -auto-approve tfplan
# 新: 引数を渡さず、ジョブ内で plan+apply を一発で
run: terraform apply -input=false -auto-approve
これで Plan と Apply の間で state がずれる余地がなくなり、stale エラーが消えました。
「でも、レビューで見た plan と実際 apply される内容がズレないの?」というのはたしかにあって、これは GitHub Environments の承認待ちが入る分、ちょっとだけリスクがあります。ただ、承認 → apply の時間はだいたい数分以内なので、その間に手動で何か変えていなければ実害はほぼないはず・・・。
5. 動作確認: PR → plan コメント → マージ → apply
ここまでで構成は固まったので、実際の使い勝手を確認します。
feature ブランチで小さく変更
dev のサンプルバケットにタグを 1 つ追加します。
tags = {
- Name = local.sample_bucket_name
- Purpose = "smoke-test for tfstate backend and CI/CD"
+ Name = local.sample_bucket_name
+ Purpose = "smoke-test for tfstate backend and CI/CD"
+ PRFlowTag = "added-to-verify-pr-plan-comment"
}
これを push して PR を作成します。
PR コメントに plan 結果が自動投稿される
GitHub Actions が走って、こんな感じで PR にコメントを残してくれます(3 環境分まとめて)。

dev は 1 to change、stg / prod は No changes. Your infrastructure matches the configuration. と、それぞれの状態に応じて表示されます。レビュアーはこのコメントを見て差分をチェックすればよさそうです。
マージしたら承認待ち → apply
PR をマージすると、main 用のワークフローが起動します。Plan が全環境通って、Apply ジョブが各環境ごとに「Required reviewers の承認待ち」状態になります。


GitHub の Actions 画面で「Review pending deployments」のボタンが出るので、内容を確認して dev → stg → prod の順に承認していきます。承認したジョブから順次 apply が走り、こうなります。

実 AWS の dev バケットでタグが追加されているか確認:
$ aws s3api get-bucket-tagging --bucket learn-tf-sample-dev
{
"TagSet": [
{ "Key": "Project", "Value": "learn-tf" },
{ "Key": "Environment", "Value": "dev" },
{ "Key": "Purpose", "Value": "smoke-test for tfstate backend and CI/CD" },
{ "Key": "ManagedBy", "Value": "terraform" },
{ "Key": "PRFlowTag", "Value": "added-to-verify-pr-plan-comment" }, ← 追加された
{ "Key": "Name", "Value": "learn-tf-sample-dev" }
]
}
stg / prod は変化なしでした。PR で意図したとおりに dev だけ更新されています。
やってみて感じたこと
- コスト: Terraform Cloud で workspace を増やすたびに気にしていた料金が、S3 + IAM だけで済むのでほぼゼロに近づきました(月数十円のレベル)。
- デプロイ経路の統一: SAM のデプロイも GitHub Actions に乗せれば、リポジトリの中で完結します。これが地味に一番うれしいかもしれません。「リリース手順書」が短くなります。
- OIDC は本当に楽: アクセスキー管理から解放されるのは、想像以上にメリットが大きいです。Secrets に何も置かなくていい清々しさがあります。
- native lock いい感じです: DynamoDB を別途用意せずに済むのは、構成がシンプルになって良かったです。Terraform v1.10 以降ならぜひ。
-
つまずきは PR/コミット履歴に残しておくと吉: 「なぜ
s3:*で広くしてるんだっけ?」みたいな疑問が将来出るので、コミットメッセージや README に経緯を残しました。
まとめ
- state は S3 + native lock(DynamoDB 不要)
- 認証は GitHub Actions OIDC(アクセスキー不要)
- 承認は GitHub Environments の Required reviewers(環境ごとに人を分けられる)
- PR で plan、main で apply、承認は環境保護で
という構成で、Terraform Cloud の代わりとしてもふつうに回せそうな手応えがありました。マイクロサービス時代の「workspace 数 × 料金」を気にしなくていいのは、長期的にかなり効いてきそうです。
詰まりやすいポイントは:
- OIDC Provider はアカウントに 1 つだけ → 既存を
dataで参照 - S3 IAM アクション名はワイルドカードで取りこぼす → ARN を絞って
s3:* - plan を job 間で受け渡すと stale エラー → apply job 内で plan+apply
このあたりだけ気をつけておけば、大きくハマることはなさそうです。同じような構成を検討してる方の参考になればうれしいです!
Discussion