GitHub Actions の OIDC で Terraform の state バケットを作る

目次
Terraform の S3 backend を使うと、「state を置く S3 バケットが先に存在していないと terraform init できない」という問題にぶつかります。しかも backend ブロックは変数展開できないので、名前を動的に組み立てることもできません。この記事では、その state バケットを GitHub Actions の OIDC + IAM ロールで手動ワークフローから作る手順を、実際にハマったポイントつきでまとめます。
なぜ手動ワークフローなのか
state バケットは backend の前提なので、Terraform 本体では管理できません(自分の state を置く場所を自分で作れない)。選択肢は主に3つ:
- 手でコンソールから作る … 再現性が低い
- ローカル state の bootstrap を別途持つ … state の置き場が増えて煩雑
- CI から
aws s3apiで作る … 冪等スクリプトにすれば一発・再現可能 ← これを採用
そして「一度きり・破壊的」な操作なので、PR では走らせず **workflow_dispatch(手動実行)**にします。
前提:backend は変数展開できない
先に釘を刺しておくと、backend ブロックはこう書けません:
# ❌ backend は初期化のごく初期に読まれるため、変数・関数・data を一切使えない
terraform {
backend "s3" {
bucket = "${var.project}-tfstate-${data.aws_caller_identity.current.account_id}"
}
}なのでバケット名はリテラルで固定し、そのバケットを外部(=今回のワークフロー)で用意します。
terraform {
backend "s3" {
bucket = "your-unique-tfstate-bucket"
key = "myapp/dev/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true # S3 ネイティブロック(Terraform 1.10+、DynamoDB 不要)
}
}key は「バケット内のどのパスに state を書くか」を指定するだけで、事前作成は不要(初回 apply で Terraform が自動生成)。用意すべきはバケットだけです。
OIDC を使う理由
CI から AWS を触るには認証が要ります。長期アクセスキーを GitHub Secrets に置く方式もありますが、GitHub OIDC + IAM ロールなら短期クレデンシャルで済み、鍵の管理・漏洩リスクがありません。今回はこちらを使います。
ただし「CI に権限を与えるための土台(OIDC プロバイダ + ロール)」だけは、AWS 側で一度だけ手動で作る必要があります(CI にはまだ権限がないので CI では作れない)。
1. OIDC プロバイダ
IAM → ID プロバイダ → プロバイダを追加:
- タイプ:
OpenID Connect - Provider URL:
https://token.actions.githubusercontent.com - Audience:
sts.amazonaws.com
2. IAM ロール(信頼ポリシー)
このリポジトリの Actions だけが assume できるよう、sub をリポジトリに絞ります:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<ACCOUNT_ID>:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com" },
"StringLike": { "token.actions.githubusercontent.com:sub": "repo:<OWNER>/<REPO>:*" }
}
}
]
}:* は全ブランチ許可。締めたいなら :ref:refs/heads/main や :environment:prod に限定します。手動実行するブランチ(例: develop)が許可されているかは要確認ポイントです。
3. 権限(最小権限)
bootstrap 用は、state バケットの作成・設定だけに絞ります:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:CreateBucket",
"s3:ListBucket",
"s3:PutBucketVersioning",
"s3:PutEncryptionConfiguration",
"s3:PutBucketPublicAccessBlock"
],
"Resource": "arn:aws:s3:::your-unique-tfstate-bucket"
}
]
}コンソールのロール作成ウィザードの「許可を追加」画面は既存ポリシーを選ぶだけで JSON を貼れません。いったんポリシー無しでロールを作成 → ロールの「インラインポリシーを作成」で JSON を貼るのが迷わず早いです。
作ったロールの ARN を、GitHub の リポジトリ変数 AWS_ROLE_ARN に登録します(後述しますが、Secret ではなく Variable です)。
bootstrap ワークフロー
workflow_dispatch で手動実行、id-token: write で OIDC トークンを取得、aws s3api を冪等に叩きます。
name: bootstrap-state
on:
workflow_dispatch:
inputs:
bucket:
description: "作成する state バケット名"
required: true
default: "your-unique-tfstate-bucket"
region:
description: "リージョン"
required: true
default: "ap-northeast-1"
permissions:
id-token: write # OIDC でロールを assume するのに必要
contents: read
jobs:
create-bucket:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ inputs.region }}
- name: Create state bucket (idempotent)
env:
BUCKET: ${{ inputs.bucket }}
REGION: ${{ inputs.region }}
run: |
set -euo pipefail
if aws s3api head-bucket --bucket "$BUCKET" 2>/dev/null; then
echo "bucket already exists: $BUCKET"
else
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" \
--create-bucket-configuration LocationConstraint="$REGION"
fi
- name: Enable versioning / encryption / block public access
env:
BUCKET: ${{ inputs.bucket }}
run: |
set -euo pipefail
aws s3api put-bucket-versioning --bucket "$BUCKET" \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket "$BUCKET" \
--server-side-encryption-configuration \
'{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration \
BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=truehead-bucket で存在チェックしているので、再実行してもエラーになりません。versioning を有効化しておけば state の履歴から復旧もできます。
なお workflow_dispatch の「Run workflow」ボタンは、ワークフローがデフォルトブランチに存在して初めて表示されます。feature ブランチに置いただけでは出ないので、develop / main にマージしてから実行します。
ハマりどころ:role-to-assume が空で「Credentials could not be loaded」
実行したら、configure-aws-credentials がこう言って 12 回リトライして落ちました:
Retry validateCredentials: attempt 1 of 12 failed: Credentials could not be loaded,
please check your action inputs: Could not load credentials from any providers.ログの with: を見ると原因が一目瞭然でした:
Run aws-actions/configure-aws-credentials@v6
with:
aws-region: ap-northeast-1
audience: sts.amazonaws.com
# ← role-to-assume が無い!role-to-assume が空だと、OIDC で assume せずに他の認証プロバイダを探し、何も無くて失敗します。原因は単純で、ロール ARN を「Secret」に登録していたこと。ワークフローは ${{ vars.AWS_ROLE_ARN }}(= Variable)を参照しているので、Secret に入れても空のままだったのです。
ロール ARN はアカウントID + ロール名で、秘密情報ではありません。リポジトリ変数(Variables タブ)に登録するのが正解です:
gh variable set AWS_ROLE_ARN --body "arn:aws:iam::<ACCOUNT_ID>:role/gha-bootstrap-role"
gh secret delete AWS_ROLE_ARN # 紛らわしい旧 Secret は削除(任意)登録し直して Re-run すると、with: に role-to-assume: arn:... が入り、OIDC で無事 assume されました。
教訓:
${{ vars.X }}は Variables、${{ secrets.X }}は Secrets。参照側と登録先が食い違うと、エラーではなく「空文字」になるので気づきにくい。ログのwith:を見れば一発で分かります。
まとめ
- Terraform の S3 backend は バケット事前作成が必須(鶏卵問題)。backend は変数展開できないので名前はリテラル固定。
- state バケットは GitHub OIDC + IAM ロールで、
workflow_dispatchの冪等ワークフローから作ると再現性が高い。 - OIDC プロバイダ + ロール(信頼ポリシー / 最小権限)だけは AWS 側で一度だけ手動。
- ロール ARN は Secret ではなく Variable。
varsとsecretsの取り違えは「空文字」になって静かに失敗するので、ログのwith:を確認する。
一度整えば、新しいアカウント・環境でも「Run workflow」一発で state バケットを用意できます。
関連記事

AWS API Gateway の REST API(v1) と HTTP API(v2) を、機能・料金・認証・Terraform の書き味の観点で比較します。公式のフィーチャーマトリクスと料金表を出典つきで整理し、「迷ったら HTTP API、これが要るなら REST」という選定基準に落とし込みます。

pnpm + Next.js プロジェクトに Renovate を導入し、automerge・依存のグルーピング・lockFileMaintenance を設定する手順を、Mend の Silent mode のハマりどころつきで紹介します。

husky と lint-staged を組み合わせ、git commit 時にステージしたファイルだけ ESLint と Prettier を自動実行する設定を、手順とハマりどころつきで紹介します。