TerraformとGitHub Actionsは「どちらを選ぶか」で迷う2択ではありません。Terraformでインフラを定義し、その適用をGitHub Actionsが自動で回す――役割の違う道具を組み合わせる関係です。本記事は、AWS上でこの2つをつなぐCI/CDを、2026年時点の推奨構成で構築する手順をまとめます。アクセスキーを保存しないOIDC認証、DynamoDBを使わないS3ネイティブロック(Terraform 1.11以降)、プルリクエストでplan・mainマージでapplyという流れ、そして本番適用を承認制にするところまで、実際に動くコードで解説します。
まとめ:最新構成の要点
- 役割分担:Terraform=インフラ定義(IaC)、GitHub Actions=それを実行するCI/CD基盤。競合ではなく補完。
- 認証はOIDC:アクセスキーをGitHub Secretsに保存する構成は避け、一時認証(keyless)でIAMロールを引き受ける。
- StateはS3+ネイティブロック:Terraform 1.11以降は
use_lockfile = trueでロックでき、DynamoDBは不要。既存のDynamoDBロックはレガシー。 - フロー:プルリクエストで
terraform plan、mainマージでterraform apply。本番applyはGitHub Environmentsの承認で保護する。
以下、この構成を「認証→State→ワークフロー→承認」の順に作っていきます。
TerraformとGitHub Actionsの役割分担(「対」ではない)
「terraform vs github actions」で検索されることが多いものの、両者は比較して片方を選ぶものではありません。Terraformはあるべきインフラの状態をコードで宣言し、実際のAWSリソースをその状態へ近づけるツールです。GitHub Actionsはコードのpushやプルリクエストをきっかけに任意のコマンドを実行するCI/CD基盤で、その中でTerraformを走らせる器になります。
| 観点 | Terraform | GitHub Actions |
|---|---|---|
| 役割 | インフラ定義(IaC) | 実行・自動化基盤(CI/CD) |
| 入力 | .tf ファイル | ワークフロー YAML |
| 成果 | AWSリソースの作成・変更 | plan/apply の自動実行と承認 |
| 単体でできること | 手元での apply | スクリプト実行(IaCは持たない) |
つまり本記事の構成は、Terraformが「何を作るか」を担い、GitHub Actionsが「いつ・誰の承認で適用するか」を担う分業です。同じ承認統制をセルフホストのサーバとプルリクエストのコメントで組む方式は、AtlantisでTerraformをPR運用する構成と権限設計で比較しています。
構築の全体像と前提バージョン
作業は次の4ステップです。(1) GitHub ActionsからAWSへOIDCで接続する、(2) TerraformのStateをS3で管理しロックを有効化する、(3) plan/applyのワークフローを書く、(4) 本番applyを承認制にする。前提となるバージョンは以下を想定します。
- Terraform 1.11以降(S3ネイティブロックがGAになったバージョン。執筆時点の最新はTerraform v1.14系)
- AWSプロバイダ v5〜v6系(
aws_s3_bucket_versioningなどのサブリソース分離はv4.0.0以降の仕様) - actions/checkout@v4 ・ hashicorp/setup-terraform@v4 ・ aws-actions/configure-aws-credentials@v4(アクションのメジャーは更新が速い。configure-aws-credentialsはv6系、github-scriptはv7系が最新なので、利用時に各リポジトリで最新タグを確認する)
OIDCでGitHub ActionsからAWSに接続する(アクセスキー不要)
アクセスキー保存を避けOIDCにする理由
かつては AWS_ACCESS_KEY_ID と AWS_SECRET_ACCESS_KEY をGitHub Secretsに保存する方法が一般的でした。しかしこれは長期間有効な認証情報がリポジトリ側に残り続ける構成で、漏えい時の影響が大きく、ローテーションの運用負担もかかります。新規に組むなら採用すべきではありません。OIDCを使えば、ジョブ実行のたびにGitHubが発行する短命なトークンでIAMロールを引き受けるため、保存する鍵がゼロになります。Google Cloud側で同じ設計を敷く場合は、IAM OIDCプロバイダと信頼ポリシーの代わりにWorkload Identityプールと属性条件を使う形になり、手順はgoogle-github-actions/authとは?Workload Identity連携の設定と移行判断にまとめています。
OIDCプロバイダとIAMロールの作成
まずAWSにGitHubのOIDCプロバイダを登録し、GitHub Actionsが引き受けるロールを作ります。ここで重要なのが信頼ポリシー(trust policy)です。古い記事にありがちな Principal = { Service = "ec2.amazonaws.com" } と sts:AssumeRole の組み合わせでは、GitHub Actionsからこのロールは引き受けられません。GitHubのOIDCを信頼するには、Federated プリンシパルと sts:AssumeRoleWithWebIdentity を使います。
# oidc.tf
# GitHubのOIDCプロバイダをAWSに登録する(AWSアカウントにつき1つでよい)
resource "aws_iam_openid_connect_provider" "github_actions" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
# 近年のAWSプロバイダでは thumbprint_list を省略できる(AWSがCAを自動検証)
}
# iam.tf
# GitHub Actions が一時認証で引き受けるロール
resource "aws_iam_role" "gha_terraform" {
name = "github-actions-terraform"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = aws_iam_openid_connect_provider.github_actions.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
# aud(対象者)は sts.amazonaws.com 固定
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
# sub でどのリポジトリ・どのブランチからの実行かを限定する
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:ref:refs/heads/main"
}
}
}]
})
}
sub の条件は必ず絞り込みます。上の例は「my-org/my-repo の main ブランチからの実行」だけを許可します。ここを緩くする(例:ワイルドカードで全ブランチ・全リポジトリを許可)と、意図しないワークフローからロールを引き受けられてしまいます。プルリクエスト用に別条件を足す場合は repo:my-org/my-repo:pull_request のようにイベント単位で明示します。
IAM権限の最小化
付与するIAMポリシーも、s3:* や ec2:*、まして Action = "*" のようなワイルドカードは避けます。Stateの読み書きに必要な権限と、デプロイ対象のサービスに絞った権限だけを付けるのが原則です。次はState用バケットへのアクセスに限定した例で、デプロイ対象(EC2やRDSなど)の権限は対象ごとに別途追加します。
# iam.tf(続き)
# ワイルドカードではなく、実行に必要な権限だけを付ける
resource "aws_iam_role_policy" "gha_terraform" {
name = "terraform-exec"
role = aws_iam_role.gha_terraform.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject", "s3:ListBucket"]
Resource = [
"arn:aws:s3:::my-terraform-state-bucket",
"arn:aws:s3:::my-terraform-state-bucket/*"
]
}]
})
}
StateをS3で管理し、ネイティブロックを使う
State用S3バケットの安全な作成
StateファイルにはリソースIDや、場合によっては機密値が含まれます。絶対に公開してはいけません。バケットポリシーで Principal = "*" に s3:GetObject / s3:PutObject を許可するような設定(誰でも読み書きできる状態)は重大なミスです。バージョニング・暗号化・パブリックアクセスブロックをそろえて作成します。なお versioning や暗号化は、AWSプロバイダv4以降ではバケット本体と別リソースに分離されています。
# s3.tf
resource "aws_s3_bucket" "terraform_state" {
bucket = "my-terraform-state-bucket"
}
# バージョニング(誤操作や破損からのstate復旧に必須)
resource "aws_s3_bucket_versioning" "state" {
bucket = aws_s3_bucket.terraform_state.id
versioning_configuration {
status = "Enabled"
}
}
# サーバーサイド暗号化
resource "aws_s3_bucket_server_side_encryption_configuration" "state" {
bucket = aws_s3_bucket.terraform_state.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}
# パブリックアクセスは全面ブロック(stateは絶対に公開しない)
resource "aws_s3_bucket_public_access_block" "state" {
bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
backendでのネイティブロック有効化
複数人やCIから同時にapplyするとStateが壊れるため、ロックが要ります。Terraform 1.11でS3ネイティブロックが正式版(GA)になり、use_lockfile = true を付けるだけでS3上のロックファイルで排他制御できるようになりました。これまで必須だったDynamoDBテーブルは不要です。
# backend.tf
terraform {
required_version = ">= 1.11"
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "prod/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true # S3ネイティブロック(DynamoDBは不要)
}
}
DynamoDBロックからの移行(レガシー扱い)
これから構築するならDynamoDBは作らなくて構いません。state自体の読み取りやmovedブロック・removedブロックによる書き換えまで含めた運用は、Terraform stateの構造とS3バックエンド・移動削除の安全手順で扱っています。既存プロジェクトがDynamoDBロックを使っている場合は、チームとCIランナー全員がTerraform 1.11以降になるまで、dynamodb_table と use_lockfile を併記して段階移行します。全員の移行が済んだら dynamodb_table を外し、テーブルを削除します。dynamodb_table は1.11で非推奨となり、将来のバージョンで削除される予定です。
# 既存のDynamoDBロックから移行する場合は、一時的に両方を併記する
terraform {
backend "s3" {
bucket = "my-terraform-state-bucket"
key = "prod/terraform.tfstate"
region = "ap-northeast-1"
use_lockfile = true # 新:S3ネイティブロック
dynamodb_table = "terraform-locks" # 旧:全員が1.11以降になったら削除
}
}
PRでplan・mainマージでapplyするワークフロー
ワークフロー全体の構成
プルリクエスト時は terraform plan で差分を確認し、mainへのマージ(push)時に terraform apply を実行します。認証は先ほどのOIDCロールを role-to-assume で指定するだけで、鍵の受け渡しはありません。permissions の id-token: write がOIDCトークン発行に必須です。
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
id-token: write # OIDCトークン発行に必須
contents: read
pull-requests: write # plan結果をPRにコメントするため
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v4
- name: Terraform Init
run: terraform init
- name: Terraform Plan
if: github.event_name == 'pull_request'
run: terraform plan -no-color
PRへのplan結果コメント(差分レビュー導線)
planのログをActionsの実行画面まで見に行くのは手間で、レビューが形骸化しがちです。plan結果をプルリクエストにコメントとして貼れば、差分をその場でレビューできます。hashicorp/setup-terraform はCLIをラップして stdout を出力として公開するため、それを github-script でコメント本文に流し込みます。
# plan の結果をPRにコメントして差分レビューできるようにする
- name: Terraform Plan
id: plan
if: github.event_name == 'pull_request'
run: terraform plan -no-color
- name: Comment plan on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const body = `#### Terraform Plan: \`${{ steps.plan.outcome }}\`
<details><summary>詳細を開く</summary>
\`\`\`
${{ steps.plan.outputs.stdout }}
\`\`\`
</details>`
await github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
})
本番applyを承認制にする(GitHub Environments)
mainにマージされたら自動でapplyが走る構成は便利ですが、本番環境では「人が承認してから適用したい」場面が多くあります。GitHub Environmentsを使うと、applyジョブを特定の環境(例:production)に紐づけ、その環境にRequired reviewers(必須レビュアー)を設定できます。承認されるまでジョブは待機し、指定した人が承認して初めてapplyが実行されます。「github approve」の運用はこの仕組みで実現します。
# push(mainマージ)時だけ動く apply ジョブ。承認を挟む
apply:
needs: plan
if: github.event_name == 'push'
runs-on: ubuntu-latest
environment: production # Settings→Environmentsで Required reviewers を設定
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v4
- run: terraform init
- run: terraform apply -auto-approve
設定はリポジトリの Settings → Environments で対象環境を作り、Required reviewers に承認者を登録するだけです。ワークフロー側は environment: production を書くことでゲートが有効になります。
つまずきやすいポイントと対処
この構成で実際に詰まりやすいのは、認証・ロック・権限の3か所です。
- ロールが引き受けられない:信頼ポリシーが
sts:AssumeRole(EC2やサービス向け)のままだとGitHub Actionsからは引き受けられません。sts:AssumeRoleWithWebIdentityとFederatedプリンシパル、aud/sub条件を確認します。 - sub条件のミスマッチ:ブランチ名やリポジトリ名が
subの条件と一致していないと弾かれます。逆に緩すぎると別リポジトリから引き受けられるため、範囲は最小にします。2026年7月15日以降に作ったリポジトリはsubの形式が変わるため、トークンのsubの確かめ方と直し方はconfigure-aws-credentialsのOIDC設定とsub不一致エラーの直し方で解説しています。 - ロックが残る:ジョブが強制終了するとロックが解放されず、以降のplan/applyが止まります。ロックIDを確認して解除します。
# ジョブ強制終了などでロックが残り、planもapplyも通らないとき
terraform force-unlock <LOCK_ID>
あわせて、CIの冒頭に terraform fmt -check と terraform validate を入れておくと、書式ゆれや構文エラーをapply前に落とせます。書式の基準はTerraformのコーディング規約を参照してください。
よくある質問
TerraformとGitHub Actionsはどちらを使うべきですか?
どちらか一方ではなく両方使います。Terraformでインフラを定義し、その適用をGitHub Actionsで自動化する組み合わせが本記事の構成です。両者は競合しません。
StateロックにDynamoDBは今も必要ですか?
Terraform 1.11以降は use_lockfile = true によるS3ネイティブロックが使えるため、新規構築では不要です。dynamodb_table は非推奨となり、将来のバージョンで削除される予定です。既存環境は全員が1.11以降になったら移行してください。
アクセスキーをGitHub Secretsに保存する方法ではだめですか?
動作はしますが推奨しません。長期有効な認証情報がリポジトリ側に残るためです。OIDCなら実行のたびに短命なトークンで認証でき、保存する鍵が不要になります。
本番のapplyを手動承認にするにはどうしますか?
GitHub EnvironmentsのRequired reviewersを使います。applyジョブに environment: production を付け、その環境に承認者を設定すると、承認されるまでapplyは実行されません。
.gitignoreには何を入れるべきですか?
ローカルの作業ディレクトリとstate、機密を含む変数ファイルを除外します。stateはS3で管理するためリポジトリには含めません。
# .gitignore
.terraform/
*.tfstate
*.tfstate.*
crash.log
*.tfvars # 変数ファイルに機密値を含む場合