hashicorp/setup-terraform@v3 with a two-job workflow -- terraform plan on PRs for review, terraform apply on merge to main with manual approval -- authenticated via OIDC federation.hashicorp/setup-terraform@v3 + terraform plan -out=tfplan + terraform apply tfplan.terraform apply without a saved plan file -- always use plan -out=tfplan then apply tfplan.terraform apply automatically on pull requests -- always require manual approval.terraform.tfstate.required_version -- mismatch can corrupt state.terraform plan -out=tfplan -- applying without saved plan risks drift.| 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 |
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
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.
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.
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.
# .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
# .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
# ❌ BAD — apply without plan file may apply different changes
- run: terraform plan
- run: terraform apply -auto-approve
# ✅ GOOD — plan -out saves exact changes, apply uses that plan
- run: terraform plan -out=tfplan
- run: terraform apply tfplan
# ❌ BAD — static keys can leak
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
# ✅ 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
required_version. [src2]concurrency groups. [src1]id-token: write permission. Fix: add at job level. [src6]sensitive = true. [src4]# 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 | 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 |
| 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 |
terraform plan output can contain sensitive data. Mark variables as sensitive = true.actions/cache for .terraform/providers/.concurrency groups.hashicorp/setup-terraform wraps output. Use terraform_wrapper: false if interfering.