GitHub Actions with Terraform: Plan on PR, Apply on Merge
The manual Terraform workflow — terraform plan on your laptop, peer-review the output in Slack, terraform apply if it looks right — breaks down around the time your team hits five people. Nobody’s looking at the same state, applies happen from different machines with different provider versions, and the audit trail is whatever the last person typed in a Slack message. Moving Terraform into GitHub Actions solves all three problems: the plan runs in a clean environment, the output is pinned to the pull request, and every apply is recorded in Actions history.
This guide covers the complete CI/CD setup: S3 state backend, OIDC authentication, plan-on-PR workflow, apply-on-merge, and the production approval gate that keeps automated apply from being a bad idea.
State Backend First
Before writing any Actions workflows, set up remote state. Local state breaks the moment two people work on the same repository. S3 with DynamoDB locking is the standard AWS setup:
# backend.tf
terraform {
backend "s3" {
bucket = "my-terraform-state-prod"
key = "infrastructure/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
Create the state bucket and lock table once:
# State bucket
aws s3api create-bucket \
--bucket my-terraform-state-prod \
--region us-east-1
# Enable versioning (lets you recover from bad applies)
aws s3api put-bucket-versioning \
--bucket my-terraform-state-prod \
--versioning-configuration Status=Enabled
# Enable server-side encryption
aws s3api put-bucket-encryption \
--bucket my-terraform-state-prod \
--server-side-encryption-configuration '{
"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]
}'
# DynamoDB lock table
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
The DynamoDB table prevents concurrent applies from corrupting state. Without it, two simultaneous terraform apply runs can interleave writes and produce broken state. With it, one apply blocks until the other finishes.
OIDC Authentication
Don’t put AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in GitHub Secrets. Use OIDC. The IAM role trust policy that lets GitHub Actions assume the role:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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:myorg/myrepo:*"
}
}
}]
}
The sub condition scopes access to your specific repository. You can tighten it further by requiring a specific branch or environment:
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:ref:refs/heads/main"
This restricts the apply role to workflows running on the main branch only — plan workflows on feature branches use a read-only role that can’t make changes. The full OIDC setup process is covered in the GitHub Actions AWS deploy guide.
Create two roles: a read-only plan role (S3 state read, all Describe/List/Get permissions) and a apply role (S3 state read+write, DynamoDB write, and whatever Terraform actually creates). Giving the plan role apply permissions means a plan workflow could accidentally change infrastructure if someone puts terraform apply in the wrong job.
Plan on Pull Request
This workflow runs terraform plan every time a PR is opened or updated and posts the plan output as a PR comment:
# .github/workflows/terraform-plan.yml
name: Terraform Plan
on:
pull_request:
branches: [main]
paths:
- '**.tf'
- '**.tfvars'
permissions:
id-token: write
contents: read
pull-requests: write # needed to post comments
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- name: Configure AWS credentials (plan role)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/TerraformPlanRole
aws-region: us-east-1
- name: Terraform Init
run: terraform init
- name: Terraform Format Check
run: terraform fmt -check -recursive
- name: Terraform Validate
run: terraform validate
- name: Terraform Plan
id: plan
run: |
terraform plan -no-color -out=plan.tfplan 2>&1 | tee plan_output.txt
echo "exit_code=${PIPESTATUS[0]}" >> $GITHUB_OUTPUT
- name: Post plan to PR
uses: actions/github-script@v7
if: always()
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('plan_output.txt', 'utf8');
const maxLen = 65000;
const truncated = plan.length > maxLen
? plan.slice(0, maxLen) + '\n\n... [truncated]'
: plan;
const body = `## Terraform Plan\n\`\`\`hcl\n${truncated}\n\`\`\``;
// Delete previous plan comments to avoid clutter
const comments = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
for (const comment of comments.data) {
if (comment.body.startsWith('## Terraform Plan')) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: comment.id,
});
}
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
- name: Check plan exit code
if: steps.plan.outputs.exit_code != '0'
run: exit 1
The comment deletion before posting is important for a busy PR. Without it, every push appends another plan comment, and the PR thread becomes unreadable. This script replaces the previous comment with the current plan output each time.
The paths filter in the trigger ensures plan only runs when Terraform files change — not on documentation updates or application code changes.
Apply on Merge
The apply workflow runs when changes merge to main. It uses the apply role (which has write permissions) and targets a GitHub Environment with a required reviewer:
# .github/workflows/terraform-apply.yml
name: Terraform Apply
on:
push:
branches: [main]
paths:
- '**.tf'
- '**.tfvars'
permissions:
id-token: write
contents: read
jobs:
apply:
runs-on: ubuntu-latest
environment: production # triggers required reviewer gate
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.9.0"
- name: Configure AWS credentials (apply role)
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/TerraformApplyRole
aws-region: us-east-1
- name: Terraform Init
run: terraform init
- name: Terraform Apply
run: terraform apply -auto-approve -input=false
The environment: production line is what creates the approval gate. In your GitHub repository settings, configure the production environment to require review from specific users or teams before the job runs. The workflow triggers on merge, then pauses at the apply job until an approver clicks the button. Nobody reviews a PR and a separate person approves the apply — or a single person reviews and applies if your team is small enough.
Set the apply role’s trust policy sub condition to repo:myorg/myrepo:environment:production so only workflows running in the production environment context can assume it:
"token.actions.githubusercontent.com:sub": "repo:myorg/myrepo:environment:production"
Multi-Environment Setup
For multiple environments (dev, staging, production), use matrix or separate workflows with environment-specific roles:
# Dev: auto-apply, no approval required
on:
push:
branches: [main]
jobs:
apply-dev:
environment: development
env:
TF_WORKSPACE: dev
AWS_ROLE: arn:aws:iam::111111111111:role/TerraformApplyRole
steps:
- run: terraform workspace select $TF_WORKSPACE || terraform workspace new $TF_WORKSPACE
- run: terraform apply -auto-approve
---
# Production: triggered manually or after staging succeeds
apply-prod:
environment: production # requires manual approval in settings
needs: apply-staging
env:
TF_WORKSPACE: prod
AWS_ROLE: arn:aws:iam::222222222222:role/TerraformApplyRole
Each environment uses a different AWS account role. The dev role trusts any push to main; the production role trusts only the production environment context. The IAM cross-account roles guide covers setting up the trust policies for each target account.
Caching Provider Downloads
Terraform downloads providers on every init unless you cache them. Provider downloads are the slowest part of CI initialization — the AWS provider alone is 400MB:
- name: Cache Terraform providers
uses: actions/cache@v4
with:
path: |
~/.terraform.d/plugin-cache
**/.terraform
key: terraform-${{ runner.os }}-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
terraform-${{ runner.os }}-
- name: Configure Terraform plugin cache
run: |
mkdir -p ~/.terraform.d/plugin-cache
echo 'plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"' > ~/.terraformrc
Set plugin_cache_dir in .terraformrc before running terraform init. The cache key uses the lock file hash — if you add or upgrade a provider, the lock file changes, the cache misses, and new providers download. For unchanged providers, init goes from 30-60 seconds to 2-3 seconds.
Security Scanning in the Plan Job
Add a security check before the plan to catch misconfigurations before they reach apply:
- name: Run tfsec
uses: aquasecurity/[email protected]
with:
soft_fail: true # warn, don't block; change to false to enforce
- name: Run checkov
uses: bridgecrewio/checkov-action@master
with:
directory: .
framework: terraform
soft_fail: true
Both tfsec and checkov catch common Terraform security issues: S3 buckets with public access, security groups with 0.0.0.0/0 ingress, unencrypted resources, missing logging configuration. Running them before plan means security issues surface in the PR before the plan even runs, rather than being discovered after an apply.
What Breaks Most Often
State lock not released after a failed apply. If an apply job is cancelled mid-run, the DynamoDB lock entry stays. The next apply fails with Error acquiring the state lock. Fix it:
# Find the lock info
aws dynamodb get-item \
--table-name terraform-state-lock \
--key '{"LockID": {"S": "my-terraform-state-prod/infrastructure/terraform.tfstate"}}'
# Force-unlock (get the lock ID from the state file or the error message)
terraform force-unlock LOCK_ID
Plan succeeds but apply fails with drift. The plan runs against the current state, then someone applies manually from their laptop before the workflow runs. The state changes between plan and apply. The safest fix: always plan immediately before apply in the apply workflow, even if the PR plan already showed the changes.
Provider version drift across team members. Pin provider versions in versions.tf and commit the .terraform.lock.hcl file. The lock file records the exact provider hash, ensuring every environment downloads the same binary.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.50"
}
}
required_version = ">= 1.9.0"
}
The combination of plan-on-PR and apply-on-merge is the most common Terraform CI pattern for a reason. It gives reviewers concrete output rather than a code diff, prevents off-branch applies, and maintains an audit trail in GitHub Actions history. For teams comparing CI/CD platforms, the GitHub Actions vs GitLab CI comparison shows how GitLab handles the same workflow — the concepts are identical, the YAML syntax differs.
Comments