GitHub ActionsでTerraformを動かし、プルリクエストでplan、マージ後に承認を挟んでapplyするところまでを、AWSを例に組み立てます。認証はアクセスキーを置かないOIDC、stateはS3のネイティブロックで管理します。コードはTerraform 1.17.0とaws provider 6.70.0の terraform validate、ワークフローはactionlint 1.7.12で検査したものです(2026年10月26日時点)。
手順に入る前に、よくある失敗を1つ挙げておきます。IAMロールを1本だけ作り、信頼ポリシーの sub を ref:refs/heads/main にする構成です。GitHubが発行するトークンの sub は、PRのジョブと environment 付きのジョブで別の値になります。そのためこの構成では、planもapplyもロールを引き受けられません。本記事では、ロールをplan用とapply用の2本に分けます。
まとめ:GitHub Actions×Terraform×AWSの構成要点
- 役割:Terraformがインフラの定義、GitHub Actionsがその実行と承認の仕組みを担う。どちらかを選ぶ関係ではない。
- 認証:OIDCでIAMロールを引き受ける。plan用ロールは
pull_requestとmainのpushだけ、apply用ロールはenvironment:productionだけを信頼させる。 - state:S3に置き、
use_lockfile = trueでロックする(Terraform 1.11でGA)。DynamoDBは新規構築では作らない。 - 適用:mainのplanをファイルに保存し、承認後のapplyジョブはそのファイルを適用する。承認から適用までの間にstateが変わっていれば、
Saved plan is staleで止まる。 - 承認:EnvironmentsのRequired reviewersは、Free・Pro・Teamプランではpublicリポジトリでしか使えない。privateリポジトリでは、Enterprise Cloudにするか、PRの承認をゲートにする。
全体の流れと2026年10月時点のバージョン
「terraform vs github actions」と並べて調べられることがありますが、両者は役割が違います。Terraformは .tf に書いたあるべき状態へAWSのリソースを合わせるツールです。GitHub Actionsは、pushやプルリクエストを合図にコマンドを走らせる実行基盤です。本記事の構成では、Terraformが「何を作るか」を、GitHub Actionsが「いつ、誰の承認で適用するか」を受け持ちます。
流れは次の4段です。(1) PRを作ると、plan用ロールで terraform plan を実行し、結果をPRにコメントする。(2) mainにマージすると、もう一度planしてファイルに保存する。(3) productionのEnvironmentで承認を待つ。(4) 承認されたら、apply用ロールで保存済みのplanを適用する。
| 構成要素 | 使う版 | リリース日 |
|---|---|---|
| Terraform CLI | 1.17.0 | 2026-10-21 |
| hashicorp/aws provider | 6.70.0 | 2026-10-22 |
| actions/checkout | v7(v7.0.1) | 2026-07-20 |
| hashicorp/setup-terraform | v4(v4.0.1) | 2026-05-12 |
| aws-actions/configure-aws-credentials | v6(v6.3.0) | 2026-09-15 |
| actions/upload-artifact | v7(v7.0.2) | 2026-10-07 |
| actions/download-artifact | v8(v8.0.2) | 2026-10-07 |
| actions/github-script | v9(v9.0.0) | 2026-04-09 |
Node.js 24への移行時期はActionごとに違います。checkoutはv5で移っており、setup-terraformはv4(2026-02-24)、configure-aws-credentialsはv6(2026-02-04)で移りました。configure-aws-credentials v6はランナーv2.327.1以降を要求します。checkout v7・upload-artifact v7・download-artifact v8は、ESMへの移行を含むメジャー更新です。セルフホストランナーを使う場合は、ランナーの版を先に確認してください。Terraform本体の版の確認と上げ方は、Terraformの最新バージョンの確認とアップグレード手順にまとめています。
S3でのstate管理とネイティブロックの設定
state用のバケットは、CIが管理するインフラとは別のディレクトリで、最初の1回だけ手元から作ります。CIのbackendが参照するバケットを、そのCI自身では作れないためです。バージョニングで誤って上書きしたstateを戻せるようにし、パブリックアクセスはすべてブロックします。
# bootstrap/main.tf(state用バケットはこのディレクトリだけローカルstateで作る)
resource "aws_s3_bucket" "tfstate" {
bucket = "my-org-tfstate-prod"
lifecycle {
prevent_destroy = true
}
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
CIが使うbackendでは use_lockfile = true を指定します。S3ネイティブロックはTerraform 1.10.0で追加され、1.11.0でGAになりました。ロック中は、stateと同じキーに .tflock を付けたオブジェクトが作られます。
# infra/backend.tf
terraform {
required_version = ">= 1.11"
backend "s3" {
bucket = "my-org-tfstate-prod"
key = "prod/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true
}
}
既存の構成がDynamoDBでロックしている場合は、チームとCIの全員が1.11以降になるまで、次のように両方を書いて移行します。HashiCorpのS3 backendのドキュメントは dynamodb_table を非推奨とし、将来のマイナーバージョンで削除するとしています。1.17.0で init すると Warning: Deprecated Parameter が出ますが、処理は続きます。移行が済んだら dynamodb_table の行を消してください。stateの中身や moved ブロックの扱いは、Terraform stateの構造とS3バックエンドの安全手順で扱っています。
# 移行期間だけ両方を書く(1.17.0でも dynamodb_table は Deprecated Parameter の警告付きで動く)
terraform {
backend "s3" {
bucket = "my-org-tfstate-prod"
key = "prod/terraform.tfstate"
region = "ap-northeast-1"
encrypt = true
use_lockfile = true
dynamodb_table = "terraform-locks"
}
}
OIDCプロバイダとplan用・apply用IAMロールの設定
イベント・environment別のsubクレーム
AWSのロールの信頼ポリシーは、トークンの sub クレームで、どのリポジトリのどのジョブを許すかを決めます。GitHubのOIDCリファレンスによると、sub の値は次のように変わります。pull_request の形になるのは、ジョブがenvironmentを参照していないときだけです。
| ジョブの条件 | subの値(従来形式) |
|---|---|
| PRで動き、environmentなし | repo:my-org/infra:pull_request |
| mainへのpushで動き、environmentなし | repo:my-org/infra:ref:refs/heads/main |
environment: production 付き |
repo:my-org/infra:environment:production |
この仕様を使えば、apply用ロールの信頼ポリシーを environment:production だけに絞れます。productionのEnvironmentを参照するジョブ以外は、変更権限を持つロールを引き受けられません。ただし sub で確かめられるのは、ジョブがEnvironmentを参照していることまでです。人の承認は、Environment側のRequired reviewersが担います。既定では管理者が保護ルールをバイパスしてデプロイできるので、承認を必須にするなら「Allow administrators to bypass configured protection rules」を外してください。
Terraformによるplan用・apply用IAMロールの定義
OIDCプロバイダはAWSアカウントに1つ作れば足ります。configure-aws-credentialsのREADMEによれば、証明書のthumbprintはもう不要で、指定しても無視されます。aws provider 6.70.0では thumbprint_list を省いても validate を通ります。
# iam.tf
locals {
repo = "my-org/infra"
state_bucket = "my-org-tfstate-prod"
}
# AWSアカウントに1つ。thumbprint_list は省略できる(指定しても検証には使われない)
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
# plan用:PRとmainへのpushだけが引き受けられる
data "aws_iam_policy_document" "plan_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = [
"repo:${local.repo}:pull_request",
"repo:${local.repo}:ref:refs/heads/main",
]
}
}
}
# apply用:environment: production のジョブだけが引き受けられる
data "aws_iam_policy_document" "apply_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${local.repo}:environment:production"]
}
}
}
resource "aws_iam_role" "plan" {
name = "gha-terraform-plan"
assume_role_policy = data.aws_iam_policy_document.plan_trust.json
}
resource "aws_iam_role" "apply" {
name = "gha-terraform-apply"
assume_role_policy = data.aws_iam_policy_document.apply_trust.json
}
# planは読み取り専用。applyロールには管理対象サービスの変更権限を別途付ける
resource "aws_iam_role_policy_attachment" "plan_readonly" {
role = aws_iam_role.plan.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
# plan用:stateは読むだけ。ロックファイル(.tflock)だけ作成・削除できる
data "aws_iam_policy_document" "plan_state" {
statement {
actions = ["s3:ListBucket"]
resources = ["arn:aws:s3:::${local.state_bucket}"]
}
statement {
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::${local.state_bucket}/prod/terraform.tfstate"]
}
statement {
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
resources = ["arn:aws:s3:::${local.state_bucket}/prod/terraform.tfstate.tflock"]
}
}
# apply用:stateの書き込みとロックファイルの作成・削除
data "aws_iam_policy_document" "apply_state" {
statement {
actions = ["s3:ListBucket"]
resources = ["arn:aws:s3:::${local.state_bucket}"]
}
statement {
actions = ["s3:GetObject", "s3:PutObject"]
resources = ["arn:aws:s3:::${local.state_bucket}/prod/terraform.tfstate"]
}
statement {
actions = ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"]
resources = ["arn:aws:s3:::${local.state_bucket}/prod/terraform.tfstate.tflock"]
}
}
resource "aws_iam_role_policy" "plan_state" {
name = "tfstate-read"
role = aws_iam_role.plan.id
policy = data.aws_iam_policy_document.plan_state.json
}
resource "aws_iam_role_policy" "apply_state" {
name = "tfstate-write"
role = aws_iam_role.apply.id
policy = data.aws_iam_policy_document.apply_state.json
}
output "plan_role_arn" {
value = aws_iam_role.plan.arn
}
output "apply_role_arn" {
value = aws_iam_role.apply.arn
}
apply用ロールに AdministratorAccess を付けると、手順としては動きます。ただしその場合、承認さえ通れば何でもできるロールになります。Terraformで管理しているサービスの変更権限だけを、別のポリシーで付けてください。このIAM定義自体も、手元で一度applyしてから、terraform output で表示されるロールARNをリポジトリのVariables(TF_PLAN_ROLE_ARN・TF_APPLY_ROLE_ARN)に登録します。ARNは秘密情報ではないので、Secretsでなくてかまいません。
2026年7月15日より後に作成したリポジトリのsub
GitHub.comでは、2026年7月15日より後に作成したリポジトリの sub が、所有者IDとリポジトリIDを含むimmutable形式になりました。同日より後に改名・移管したリポジトリも対象で、GitHub Enterprise Serverは対象外です。たとえばmainのpushは repo:my-org@123456/infra@456789:ref:refs/heads/main になります。名前だけの条件のままだと、Not authorized to perform sts:AssumeRoleWithWebIdentity で失敗します。手持ちのリポジトリがどちらの形式かの確かめ方と、条件の直し方は、configure-aws-credentialsのOIDC設定とsub不一致エラーの直し方で説明しています。
plan用ロールを読み取り専用にする理由
terraform plan は読むだけの操作に見えますが、PRで変更された .tf の中身をそのまま実行します。Terraform 1.17.0で次のデータソースを書いてplanすると、sh がplanの時点で実行され、ファイルが作られました。
data "external" "probe" {
program = ["sh", "-c", "echo ran-at-plan > marker.txt; echo '{\"ok\":\"1\"}'"]
}
つまり、PRを出せる人は誰でも、plan用ロールの権限で任意のコマンドを動かせます。そのためplan用ロールには、管理対象のリソースとstate本体への書き込みを許可しません。例外は、S3のロックに必要な .tflock の作成・削除だけです。AWS管理ポリシーのReadOnlyAccessも s3:Get* を含むので、S3オブジェクトの中身は読めます。機密データのバケットが同じアカウントにあるなら、管理対象サービスのDescribe・List系に絞ったポリシーに置き換えてください。
外部からのPRも確認しておきます。GitHubのドキュメントによると、フォークから来た pull_request にはSecretsが渡らず、GITHUB_TOKEN は読み取り専用になります。pull_request_target はベースリポジトリのコンテキストで動くため、PR側のコードをチェックアウトしてplanすると、上の任意実行がベースの権限で起きます。actions/checkoutは v7.0.0 から、pull_request_target と workflow_run でのフォークPRのチェックアウトをブロックしています。Terraformのワークフローは pull_request で組んでください。
PRのplanと承認後のapplyをつなぐワークフロー
ワークフローの全体を示します。Terraformのコードは infra/ に置く前提です。
# .github/workflows/terraform.yml
name: terraform
on:
pull_request:
branches: [main]
paths: ["infra/**", ".github/workflows/terraform.yml"]
push:
branches: [main]
paths: ["infra/**", ".github/workflows/terraform.yml"]
permissions:
contents: read
# PRごと・mainごとに直列化する。applyを途中で止めないよう cancel-in-progress は false
concurrency:
group: terraform-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false
env:
TF_IN_AUTOMATION: "1"
AWS_REGION: ap-northeast-1
defaults:
run:
working-directory: infra
jobs:
plan:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
outputs:
exitcode: ${{ steps.plan.outputs.exitcode }}
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: 1.17.0
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.TF_PLAN_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- run: terraform fmt -check -recursive
- run: terraform init -input=false
- run: terraform validate -no-color
# 0=差分なし 1=エラー 2=差分あり(-detailed-exitcode)
- id: plan
run: |
set +e
terraform plan -input=false -no-color -lock-timeout=5m -detailed-exitcode -out=tfplan
ec=$?
set -e
echo "exitcode=$ec" >> "$GITHUB_OUTPUT"
if [ "$ec" -eq 1 ]; then exit 1; fi
terraform show -no-color tfplan > plan.txt
# Summaryは1ステップ1MiBまで。載せきれないplanは承認に回さない
if [ "$(wc -c < plan.txt)" -gt 1000000 ]; then
echo "plan output exceeds the step summary limit" >&2
exit 1
fi
{ echo '~~~'; cat plan.txt; echo '~~~'; } >> "$GITHUB_STEP_SUMMARY"
- name: PRにplanを貼る
if: github.event_name == 'pull_request'
uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const marker = '<!-- terraform-plan -->';
let plan = fs.readFileSync('infra/plan.txt', 'utf8');
if (plan.length > 60000) plan = plan.slice(0, 60000) + '\n...(以降はActionsのSummaryを参照)';
const body = marker + '\n#### terraform plan\n\n~~~\n' + plan + '\n~~~';
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, issue_number });
const old = comments.find(c => c.user.type === 'Bot' && c.body.includes(marker));
if (old) {
await github.rest.issues.updateComment({ owner, repo, comment_id: old.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
# mainのplanだけを保存し、承認後のapplyに渡す
- name: applyジョブへplanを渡す
if: github.event_name == 'push' && steps.plan.outputs.exitcode == '2'
uses: actions/upload-artifact@v7
with:
name: tfplan
path: infra/tfplan
retention-days: 1
apply:
needs: plan
if: github.event_name == 'push' && needs.plan.outputs.exitcode == '2'
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
with:
terraform_version: 1.17.0
terraform_wrapper: false
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.TF_APPLY_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- uses: actions/download-artifact@v8
with:
name: tfplan
path: infra
- run: terraform init -input=false
# 保存済みplanを渡すと確認プロンプトなしで、そのplanの操作だけを実行する
- run: terraform apply -input=false -no-color -lock-timeout=5m tfplan
PRコメントとSummaryへのplan結果の表示
setup-terraformは既定で terraform_wrapper: true になっており、実行結果を stdout・exitcode の出力として後続のステップに渡します。ここではラッパーを外して -detailed-exitcode の終了コードを自分で受け、terraform show の結果をファイルに書いています。出力をスクリプトの文字列に直接埋め込まないためです。plan出力にクォートやバッククォートが混じると、埋め込んだスクリプトが壊れます。
コメントには目印の <!-- terraform-plan --> を入れ、同じPRへの2回目以降は既存のコメントを書き換えます。こうすればpushのたびにコメントが増えません。GitHubのコメントは長さに上限があり、setup-terraformのREADMEは65,535文字としています。ワークフローではコメントを6万文字で切り、全文は $GITHUB_STEP_SUMMARY に出しています。Summaryは1ステップ1MiBまでなので、それを超えるplanはジョブを失敗させ、差分を読めないまま承認に回らないようにしています。mainのplanもSummaryに出るので、承認者は承認画面から同じ実行のSummaryを開けば、適用される差分を読めます。
保存planによる承認済み差分の適用
applyジョブで terraform apply -auto-approve を実行すると、承認の後にもう一度planが作られます。その間に誰かが手でリソースを変えていれば、承認者が見ていない差分まで適用されます。本記事のワークフローでは、mainのplanを -out=tfplan で保存し、artifactでapplyジョブに渡しています。保存済みのplanを渡したapplyは確認を求めず、そのplanの操作だけを実行します。手元で、planの保存後に別の操作でstateを進めてから古いplanを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.
planファイルを別のジョブで適用するときは、注意が2つあります。1つ目は環境です。HashiCorpの自動化ガイドは、planとapplyを同じOS・CPUアーキテクチャ・同じproviderで動かすことを前提にしています。両ジョブで runs-on と terraform_version を揃え、.terraform.lock.hcl をコミットしてください。plan中に生成したファイルをapplyでも参照する構成なら、そのファイルもartifactで渡し、作業ディレクトリの絶対パスを揃えます(GitHubホストランナーの既定の作業ディレクトリ GITHUB_WORKSPACE は、ジョブが違っても /home/runner/work/リポジトリ名/リポジトリ名 です)。2つ目は中身です。planファイルはzipで、展開すると tfstate・tfstate-prev・tfconfig/・.terraform.lock.hcl が入っていました。stateに機密値があれば、それもartifactに載ります。artifactはリポジトリの読み取り権限があればダウンロードできるので、publicリポジトリで機密を含むplanを保存する構成は採らないでください。privateでも、読み取り権限を持つ全員に見えて困らない内容かを確かめます。retention-days: 1 で保存期間を最短にしているのもそのためです。承認待ちが保存期間を超えたときは、applyジョブだけでなく、planジョブからワークフローを再実行して新しいplanを承認してください。plan出力の読み方は、terraform planの差分の読み方とinit・apply・destroyの安全手順で解説しています。
同時実行とstateロックの関係
concurrency のグループはPR番号ごと、mainはrefごとに分けています。cancel-in-progress を省くと、実行中のジョブは取り消されません。applyの途中で取り消されると、ロックファイルが残ったり、stateが中途半端になったりします。そのため、ここは false を明示しています。既定の queue: single では、待機できる実行は1件だけで、新しい実行が来ると待機中の古い実行は置き換えられます。承認待ちの間にmainへ3回マージした場合、中間の実行は取り消され、最新のコミットのplanが次の承認対象になります(GitHubのconcurrencyのドキュメント)。PRのplanとmainのapplyはグループが違うので並行して走ることがあります。ぶつかった場合は、S3のロックと、planとapplyの両方に付けた -lock-timeout=5m で待たせます。
Required reviewersが使えるプランとprivateリポジトリでの代替
applyジョブに environment: production を書き、リポジトリの Settings → Environments でproductionを作ってRequired reviewersを登録すると、承認されるまでジョブが待機します。承認者にはユーザーまたはチームを最大6件登録でき、そのうち1人が承認すれば進みます。「Prevent self-review」を有効にすると、デプロイを起こした本人は承認できなくなります。
注意したいのはプランの制約です。GitHubのドキュメントには、Free・Pro・TeamプランではRequired reviewersとWait timerはpublicリポジトリでしか使えないとあります。社内のインフラリポジトリは多くがprivateなので、Teamプランでは承認ゲートが働きません。承認を確保する方法は、次のうち先頭の2つです。3つ目のブランチ制限は承認の代わりにはならず、承認と併用する制限です。
- GitHub Enterprise Cloudにする:privateリポジトリでもRequired reviewersを設定できる。GitHub Enterprise Serverはupload-artifact v4以降に対応していないので、掲載したartifactのActionはそのままでは動かない。
- PRの承認をゲートにする:ブランチ保護でmainへのマージに承認を必須にし、マージ後のapplyは自動で走らせる。PRのplanコメントがレビュー対象になる。ただし適用するのはマージ後に作り直したplanなので、PRで承認した差分と完全に一致する保証はない。適用する差分そのものの承認が要る運用では、この方式だけに頼らない。ブランチ保護は、Teamプランならprivateリポジトリでも使える。
- デプロイ先のブランチを制限する:Pro・TeamプランでもEnvironmentのdeployment branchesはprivateリポジトリで使えるので、productionへのデプロイをmainに限定する。apply用ロールの
environment:production条件と組み合わせれば、main以外からは変更権限を引き受けられない。
セルフホストのサーバーでPRコメントからapplyする方式は、AtlantisでTerraformをPR運用する構成と権限設計で比較しています。ワークフローの設計から運用までまとめて任せたい場合は、DevOps・CI/CD導入支援でも承ります。
つまずきやすいエラーと直し方
Not authorized to perform sts:AssumeRoleWithWebIdentity:信頼ポリシーのsubがトークンと一致していない。environment付きのジョブなのにref:の条件を書いていないか、immutable形式のリポジトリなのに名前だけの条件のままでないかを確かめる。- OIDCトークンが取れない:そのジョブの
permissionsにid-token: writeがあるかを見る。ジョブ単位でpermissionsを書くと、ワークフロー全体の指定は引き継がれない。 Error: Saved plan is stale:承認待ちの間にstateが進んだ。もう一度mainのワークフローを実行し、新しいplanを承認する。Error acquiring the state lock:ほかのジョブがロック中か、強制終了でロックが残っている。表示されたLock InfoのWhoとCreatedで、実行中のジョブがないことを確かめてから解除する。
# Error acquiring the state lock の Lock Info に出る ID を指定する
terraform force-unlock LOCK_ID
terraform fmt -check は、整形が必要なファイルがあると終了コード3で落ちます。書式の基準はTerraformのコーディング規約、静的解析をCIに足すならTFLintの導入とCI組み込みを参照してください。fmt・validate・plan以外のチェックの組み方や、GitLab CIやCircleCIとの比較は、TerraformのCI導入ガイドで扱っています。
よくある質問
TerraformとGitHub Actionsはどちらを使うべきですか?
両方を使います。Terraformでインフラを定義し、その実行と承認をGitHub Actionsに任せる組み合わせです。GitHub Actionsだけではリソースの状態を管理できず、Terraformだけでは「PRで差分を見せて承認後に適用する」流れを作れません。
StateロックにDynamoDBはまだ必要ですか?
Terraform 1.11以降を全員が使うなら不要です。use_lockfile = true でS3だけでロックできます。dynamodb_table は非推奨で、1.17.0でも警告付きで動きますが、将来のマイナーバージョンで削除される予定です。
アクセスキーをGitHub Secretsに入れる方法ではだめですか?
動きますが、期限のないキーがリポジトリ側に残り続けます。漏れたときはキーを無効にするまで使われ続け、ローテーションの手間もかかります。OIDCならジョブごとに短命な認証情報が発行されるので、保存するキーがなくなります。
.gitignoreには何を入れ、何をコミットしますか?
.terraform/・state・planファイル・機密を含む変数ファイルを除外します。.terraform.lock.hcl はproviderの版とハッシュを固定するファイルなのでコミットしてください。除外すると、planとapplyで違う版のproviderが入るおそれがあります。
# .gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
tfplan
crash.log
*.auto.tfvars
# .terraform.lock.hcl はコミットする(除外しない)
HCP TerraformやAtlantisを使わず、GitHub Actionsだけで運用してよいですか?
stateをS3に置き、承認をEnvironmentsかPRの承認で担えるなら、GitHub Actionsだけで運用できます。plan結果の保管、ポリシーチェック、実行履歴の画面を自前で作りたくない場合は、HCP Terraformのようにそれらを最初から備えたサービスを検討します。AWSの構築そのものの手順はTerraformでAWSを構築する手順にあります。