---
title: "GitHub ActionsとTerraformでAWSのCI/CDを構築する手順（OIDC・S3ネイティブロック対応）"
url: "https://www.issoh.co.jp/tech/details/2715/"
published: 2024-06-21
updated: 2026-09-29
categories: ["GitHub"]
publisher: "株式会社一創"
---

# GitHub ActionsとTerraformでAWSのCI/CDを構築する手順（OIDC・S3ネイティブロック対応）

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運用する構成と権限設計](https://www.issoh.co.jp/tech/details/16549/)で比較しています。

## 構築の全体像と前提バージョン

作業は次の4ステップです。(1) GitHub ActionsからAWSへOIDCで接続する、(2) TerraformのStateをS3で管理しロックを有効化する、(3) plan/applyのワークフローを書く、(4) 本番applyを承認制にする。前提となるバージョンは以下を想定します。

- **Terraform 1.11以降**（S3ネイティブロックがGAになったバージョン。執筆時点の最新は[Terraform v1.14](/tech/details/8928/)系）
- **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連携の設定と移行判断](https://www.issoh.co.jp/tech/details/16953/)にまとめています。

### 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バックエンド・移動削除の安全手順](https://www.issoh.co.jp/tech/details/16525/)で扱っています。既存プロジェクトが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不一致エラーの直し方](https://www.issoh.co.jp/tech/details/17939/)で解説しています。
- **ロックが残る**：ジョブが強制終了するとロックが解放されず、以降のplan/applyが止まります。ロックIDを確認して解除します。

```
# ジョブ強制終了などでロックが残り、planもapplyも通らないとき
terraform force-unlock <LOCK_ID>
```

あわせて、CIの冒頭に `terraform fmt -check` と `terraform validate` を入れておくと、書式ゆれや構文エラーをapply前に落とせます。書式の基準は[Terraformのコーディング規約](/tech/details/4083/)を参照してください。

## よくある質問

### 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   # 変数ファイルに機密値を含む場合
```

## 関連記事

- [DevOps実践者に向けたTerraform v1.14.0アップデートの概要と目的を詳しく解説](/tech/details/8928/)
- [HCP Terraformとは？旧Terraform Cloud（名称変更）の機能・料金とHCPでの位置づけを解説](/tech/details/3340/)
- [Terraformコードを一貫したスタイルで書くためのガイドライン](/tech/details/4083/)
- [Terraform 1.13 新機能まとめ: 最新アップデートのポイントとアップグレード手順を徹底解説](/tech/details/8928/)

---

出典: [GitHub ActionsとTerraformでAWSのCI/CDを構築する手順（OIDC・S3ネイティブロック対応）](<https://www.issoh.co.jp/tech/details/2715/>)（株式会社一創）
