GitHub Actions: Terraform

Type: Software Reference Confidence: 0.92 Sources: 7 Verified: 2026-02-28 Freshness: 2026-02-28

TL;DR

Constraints

Quick Reference

StepCommandNotes
Setup Terraformhashicorp/setup-terraform@v3Installs specific version
AWS OIDC authaws-actions/configure-aws-credentials@v4role-to-assume, no keys
GCP OIDC authgoogle-github-actions/auth@v2Workload identity
Azure OIDC authazure/login@v2Federated credentials
Initializeterraform initDownloads providers, configures backend
Format checkterraform fmt -check -recursiveFails on unformatted files
Validateterraform validateSyntax and consistency check
Planterraform plan -out=tfplanSaves plan for exact apply
Applyterraform apply tfplanApplies saved plan exactly
TFLintterraform-linters/setup-tflint@v4Best practice linting
Security scanaquasecurity/[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

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

VersionStatusBreaking ChangesMigration Notes
Terraform 1.9CurrentRemoved variable validation experimentStable, recommended
Terraform 1.7-1.8Activeremoved block for state removals
Terraform 1.6ActiveBuilt-in test frameworkAdd terraform test
Terraform 1.5Maintenanceimport block
setup-terraform@v3CurrentNode.js 20 runtimeUpdate from @v2
OpenTofu 1.6+AlternativeOpen-source forkCompatible configs

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
Infrastructure managed with TerraformUsing Pulumi or CDKPulumi/CDK-specific CI
Project on GitHubProject on GitLabGitLab CI Terraform integration
PR-based infra reviewContinuous reconciliationTerraform Cloud or Spacelift
Simple to moderate complexity100+ modulesTerragrunt or Terraform Cloud

Important Caveats

Related Units