GitHub Actions: Terraform
GitHub Actions reference: Terraform
TL;DR
- Bottom line: Use
hashicorp/setup-terraform@v3with a two-job workflow --terraform planon PRs for review,terraform applyon merge to main with manual approval -- authenticated via OIDC federation. - Key tool/command:
hashicorp/setup-terraform@v3+terraform plan -out=tfplan+terraform apply tfplan. - Watch out for: Running
terraform applywithout a saved plan file -- always useplan -out=tfplanthenapply tfplan. - Works with: Terraform 1.x (1.5+), AWS/GCP/Azure via OIDC, remote state backends.
Constraints
- NEVER run
terraform applyautomatically on pull requests -- always require manual approval. - Store state in a remote backend (S3+DynamoDB, GCS, Terraform Cloud) -- never commit
terraform.tfstate. - Use OIDC federation for cloud authentication -- never store long-lived access keys as secrets.
- Pin Terraform version in workflow AND
required_version-- mismatch can corrupt state. - Always use
terraform plan -out=tfplan-- applying without saved plan risks drift. - Enable state locking (DynamoDB for S3) -- concurrent applies without locking corrupt state.
Quick Reference
| Step | Command | Notes |
|---|---|---|
| Setup Terraform | hashicorp/setup-terraform@v3 | Installs specific version |
| AWS OIDC auth | aws-actions/configure-aws-credentials@v4 | role-to-assume, no keys |
| GCP OIDC auth | google-github-actions/auth@v2 | Workload identity |
| Azure OIDC auth | azure/login@v2 | Federated credentials |
| Initialize | terraform init | Downloads providers, configures backend |
| Format check | terraform fmt -check -recursive | Fails on unformatted files |
| Validate | terraform validate | Syntax and consistency check |
| Plan | terraform plan -out=tfplan | Saves plan for exact apply |
| Apply | terraform apply tfplan | Applies saved plan exactly |
| TFLint | terraform-linters/setup-tflint@v4 | Best practice linting |
| Security scan | aquasecurity/[email protected] | Security scanning |
Decision Tree
START
├── Single environment (dev or prod)?
│ ├── YES → Single workspace, plan on PR, apply on merge
│ └── NO ↓
├── Multiple environments (dev/staging/prod)?
│ ├── YES → Terraform workspaces or separate state files per env
│ └── NO ↓
├── Using Terraform Cloud?
│ ├── YES → TFC remote backend, API-driven runs
│ └── NO ↓
├── Multi-account AWS?
│ ├── YES → OIDC role per account, workspace-based role selection
│ └── NO ↓
└── DEFAULT → Two-job: plan (PR) + apply (merge), OIDC, S3 backend
Step-by-Step Guide
1. Create the plan workflow for PRs
Run terraform plan on every PR and post output as comment. [src1]
name: Terraform Plan
on:
pull_request:
paths: ['terraform/**']
permissions:
contents: read
pull-requests: write
id-token: write
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.5
- run: terraform init
- run: terraform fmt -check -recursive
- run: terraform validate
- run: terraform plan -no-color -out=tfplan
id: plan
- uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '### Terraform Plan\n```\n' + `${{ steps.plan.outputs.stdout }}`.substring(0, 60000) + '\n```'
});
Verify: Create PR modifying terraform/ → plan output appears as PR comment.
2. Create the apply workflow
Apply on merge to main with environment protection. [src1]
name: Terraform Apply
on:
push:
branches: [main]
paths: ['terraform/**']
permissions:
contents: read
id-token: write
jobs:
apply:
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.5
- run: terraform init
- run: terraform plan -out=tfplan
- run: terraform apply tfplan
Verify: Merge PR → apply job waits for approval → approve → resources created.
3. Configure OIDC federation for AWS
Set up IAM role with OIDC trust policy for GitHub Actions. [src6]
{
"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:*"
}
}
}]
}
Verify: aws sts get-caller-identity in workflow shows assumed role ARN.
Code Examples
Complete two-job workflow (plan + apply)
# .github/workflows/terraform.yml
name: Terraform CI/CD
on:
push:
branches: [main]
paths: ['terraform/**']
pull_request:
paths: ['terraform/**']
permissions:
contents: read
pull-requests: write
id-token: write
env:
TF_VERSION: "1.7.5"
jobs:
plan:
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- run: terraform init
- run: terraform fmt -check -recursive
- run: terraform validate
- run: terraform plan -no-color -out=tfplan
id: plan
apply:
needs: plan
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
environment: production
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: ${{ env.TF_VERSION }}
- run: terraform init
- run: terraform plan -out=tfplan
- run: terraform apply tfplan
Multi-environment with workspaces
# .github/workflows/terraform-multi-env.yml
name: Terraform Multi-Environment
on:
push:
branches: [main]
paths: ['terraform/**']
jobs:
deploy:
runs-on: ubuntu-latest
strategy:
max-parallel: 1
matrix:
environment: [dev, staging, production]
environment: ${{ matrix.environment }}
defaults:
run:
working-directory: terraform
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.5"
- run: terraform init
- run: terraform workspace select ${{ matrix.environment }} || terraform workspace new ${{ matrix.environment }}
- run: terraform plan -var-file="envs/${{ matrix.environment }}.tfvars" -out=tfplan
- run: terraform apply tfplan
Anti-Patterns
Wrong: Applying without saved plan
# ❌ BAD — apply without plan file may apply different changes
- run: terraform plan
- run: terraform apply -auto-approve
Correct: Using saved plan file
# ✅ GOOD — plan -out saves exact changes, apply uses that plan
- run: terraform plan -out=tfplan
- run: terraform apply tfplan
Wrong: Using long-lived access keys
# ❌ BAD — static keys can leak
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Correct: Using OIDC federation
# ✅ GOOD — short-lived credentials, no secrets to rotate
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: us-east-1
Common Pitfalls
- Terraform version mismatch: CI uses different version than local. Fix: pin in workflow AND
required_version. [src2] - State lock timeout: Concurrent runs contend for lock. Fix: use
concurrencygroups. [src1] - Plan output too large: 65536 char limit for PR comments. Fix: truncate or link to run. [src3]
- OIDC token not available: Missing
id-token: writepermission. Fix: add at job level. [src6] - Backend init failure: Missing S3 bucket or DynamoDB table. Fix: create backend resources first. [src1]
- Secrets in plan output: Terraform shows secrets. Fix: mark variables as
sensitive = true. [src4]
Diagnostic Commands
# Check Terraform version
terraform version
# Verify backend configuration
terraform init -backend=true
# List managed resources
terraform state list
# Show current workspace
terraform workspace show
# Check formatting
terraform fmt -check -recursive -diff
# Validate configuration
terraform validate
# Show providers
terraform providers
# Check OIDC identity (AWS)
aws sts get-caller-identity
Version History & Compatibility
| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Terraform 1.9 | Current | Removed variable validation experiment | Stable, recommended |
| Terraform 1.7-1.8 | Active | removed block for state removals | — |
| Terraform 1.6 | Active | Built-in test framework | Add terraform test |
| Terraform 1.5 | Maintenance | import block | — |
| setup-terraform@v3 | Current | Node.js 20 runtime | Update from @v2 |
| OpenTofu 1.6+ | Alternative | Open-source fork | Compatible configs |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Infrastructure managed with Terraform | Using Pulumi or CDK | Pulumi/CDK-specific CI |
| Project on GitHub | Project on GitLab | GitLab CI Terraform integration |
| PR-based infra review | Continuous reconciliation | Terraform Cloud or Spacelift |
| Simple to moderate complexity | 100+ modules | Terragrunt or Terraform Cloud |
Important Caveats
terraform planoutput can contain sensitive data. Mark variables assensitive = true.- GitHub Actions cache does not cache Terraform providers by default. Use
actions/cachefor.terraform/providers/. - Concurrent applies against same state fail with lock errors. Use
concurrencygroups. - OIDC trust policies should restrict to specific repos and branches.
hashicorp/setup-terraformwraps output. Useterraform_wrapper: falseif interfering.