---
# === IDENTITY ===
id: software/devops/github-actions-terraform/2026
canonical_question: "GitHub Actions reference: Terraform"
aliases:
  - "GitHub Actions Terraform plan apply workflow"
  - "Terraform CI/CD GitHub Actions pipeline"
  - "GitHub Actions infrastructure as code Terraform"
  - "Terraform PR plan apply automation GitHub"
entity_type: software_reference
domain: software > devops > GitHub Actions Terraform
region: global
jurisdiction: global
temporal_scope: 2024-2026

# === VERIFICATION ===
last_verified: 2026-02-28
confidence: 0.92
version: 1.0
first_published: 2026-02-28

# === TEMPORAL VALIDITY ===
temporal_validity:
  status: stable
  last_breaking_change: "Terraform 1.6 introduced test framework (Oct 2023); hashicorp/setup-terraform@v3 replaced v2 (2024)"
  next_review: 2026-08-27
  change_sensitivity: medium

# === CONSTRAINTS ===
constraints:
  - "NEVER run terraform apply automatically on pull requests -- always require manual approval for infrastructure changes"
  - "Store Terraform state in a remote backend (S3, GCS, Terraform Cloud) -- never commit terraform.tfstate to version control"
  - "Use OIDC federation for cloud provider authentication (AWS, GCP, Azure) instead of long-lived access keys"
  - "Pin Terraform version in workflow AND in required_version constraint in terraform block -- version mismatch causes state corruption"
  - "Always run terraform plan before apply and post the plan output to the PR for review"

# === SKIP CONDITIONS ===
skip_this_unit_if:
  - condition: "Need OpenTofu instead of Terraform"
    use_instead: "software/devops/opentofu-cicd/2026"
  - condition: "Need Pulumi infrastructure as code"
    use_instead: "software/devops/pulumi-cicd/2026"
  - condition: "Terraform state conflict debugging"
    use_instead: "software/debugging/terraform-state-conflicts/2026"

# === AGENT HINTS ===
inputs_needed:
  - key: "cloud_provider"
    question: "Which cloud provider does your Terraform code target?"
    type: choice
    options: ["AWS", "GCP", "Azure", "Multi-cloud", "Other"]
  - key: "state_backend"
    question: "Where is your Terraform state stored?"
    type: choice
    options: ["S3 + DynamoDB", "GCS", "Azure Blob", "Terraform Cloud", "Local (needs migration)"]

# === DISTRIBUTION ===
canonical_source: "https://knowledgelib.io/software/devops/github-actions-terraform/2026"
suggested_citation: "Source: knowledgelib.io -- AI Knowledge Library (verified 2026-02-28)"

# === RELATED UNITS ===
related_kos:
  depends_on: []
  related_to:
    - id: "software/debugging/terraform-state-conflicts/2026"
      label: "Terraform State Conflict Debugging"
    - id: "software/devops/github-actions-docker/2026"
      label: "GitHub Actions Docker Build and Push"
  solves: []
  alternative_to:
    - id: "software/devops/gitlab-ci-pipeline/2026"
      label: "GitLab CI Basic Pipeline"
  often_confused_with:
    - id: "software/debugging/terraform-state-conflicts/2026"
      label: "Terraform State Conflict Debugging"

# === SOURCES ===
sources:
  - id: src1
    title: "Automate Terraform with GitHub Actions"
    author: HashiCorp
    url: https://developer.hashicorp.com/terraform/tutorials/automation/github-actions
    type: official_docs
    published: 2024-01-15
    reliability: authoritative
  - id: src2
    title: "hashicorp/setup-terraform GitHub Action"
    author: HashiCorp
    url: https://github.com/hashicorp/setup-terraform
    type: official_docs
    published: 2024-06-01
    reliability: authoritative
  - id: src3
    title: "Terraform with GitHub Actions: How to Manage & Scale"
    author: Spacelift
    url: https://spacelift.io/blog/github-actions-terraform
    type: technical_blog
    published: 2024-09-15
    reliability: high
  - id: src4
    title: "Terraform GitHub Actions Guide 2025: Secure CI/CD for IaC"
    author: DevOps Tooling
    url: https://thedevopstooling.com/terraform-github-actions-guide/
    type: technical_blog
    published: 2025-01-10
    reliability: moderate_high
  - id: src5
    title: "Workflow syntax for GitHub Actions"
    author: GitHub
    url: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions
    type: official_docs
    published: 2024-01-15
    reliability: authoritative
  - id: src6
    title: "Configuring OpenID Connect in Amazon Web Services"
    author: GitHub
    url: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services
    type: official_docs
    published: 2024-01-15
    reliability: authoritative
  - id: src7
    title: "dflook/terraform-github-actions"
    author: Daniel Flook
    url: https://github.com/dflook/terraform-github-actions
    type: community_resource
    published: 2024-06-01
    reliability: moderate_high
---

# GitHub Actions: Terraform

## TL;DR

- **Bottom line**: Use `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 to your cloud provider.
- **Key tool/command**: `hashicorp/setup-terraform@v3` + `terraform plan -out=tfplan` + `terraform apply tfplan`.
- **Watch out for**: Running `terraform apply` without a saved plan file -- always use `plan -out=tfplan` then `apply tfplan` to ensure the exact reviewed changes are applied.
- **Works with**: Terraform 1.x (1.5+), AWS/GCP/Azure via OIDC, remote state backends (S3, GCS, Terraform Cloud).

## Constraints
<!-- Agents: read this section before recommending any action from this unit.
     These are hard constraints that must not be violated. -->

- NEVER run `terraform apply` automatically on pull requests -- always require manual approval via GitHub Environments.
- Store Terraform state in a remote backend (S3+DynamoDB, GCS, Azure Blob, Terraform Cloud) -- never commit `terraform.tfstate` to Git.
- Use OIDC federation for cloud provider authentication -- never store long-lived AWS access keys or GCP service account keys as GitHub Secrets.
- Pin Terraform version in the workflow AND in `required_version` in the `terraform {}` block -- version mismatch can corrupt state.
- Always run `terraform plan -out=tfplan` and save the plan file -- applying without a plan file risks drift between review and apply.
- Enable state locking (DynamoDB for S3, built-in for GCS/Terraform Cloud) -- concurrent applies without locking corrupt state.

## Quick Reference

| Step | Command | Notes |
|---|---|---|
| Setup Terraform | `hashicorp/setup-terraform@v3` | Installs specific Terraform version |
| AWS OIDC auth | `aws-actions/configure-aws-credentials@v4` | `role-to-assume`, no access keys needed |
| GCP OIDC auth | `google-github-actions/auth@v2` | Workload identity federation |
| Azure OIDC auth | `azure/login@v2` | Federated identity credentials |
| Initialize | `terraform init` | Downloads providers, configures backend |
| Format check | `terraform fmt -check -recursive` | Fails on unformatted files |
| Validate | `terraform validate` | Checks syntax and internal consistency |
| Plan | `terraform plan -out=tfplan` | Saves plan for exact apply |
| Plan (PR comment) | `terraform plan -no-color` + comment | Post plan output to PR for review |
| Apply | `terraform apply tfplan` | Applies the saved plan exactly |
| State list | `terraform state list` | Diagnostic: list managed resources |
| Workspace select | `terraform workspace select <env>` | For multi-environment setups |
| TFLint | `terraform-linters/setup-tflint@v4` | Lint Terraform code for best practices |
| tfsec/Trivy | `aquasecurity/tfsec-action@v1.0.3` | Security scanning for Terraform |

## Decision Tree

```
START
├── Single environment (dev or prod)?
│   ├── YES → Single workspace, plan on PR, apply on merge to main
│   └── NO ↓
├── Multiple environments (dev/staging/prod)?
│   ├── YES → Terraform workspaces or separate state files per env
│   └── NO ↓
├── Using Terraform Cloud?
│   ├── YES → Use TFC remote backend, API-driven runs (no apply in GHA)
│   └── NO ↓
├── Multi-account AWS?
│   ├── YES → OIDC role per account, workspace-based role selection
│   └── NO ↓
├── Need security scanning?
│   ├── YES → Add TFLint + tfsec/Trivy as separate job before plan
│   └── NO ↓
└── DEFAULT → Two-job: plan (PR) + apply (merge), OIDC auth, S3 backend
```

## Step-by-Step Guide

### 1. Create the plan workflow for pull requests

Create `.github/workflows/terraform-plan.yml` to run plan on every PR. [src1]

```yaml
name: Terraform Plan

on:
  pull_request:
    paths:
      - 'terraform/**'
      - '.github/workflows/terraform-*.yml'

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

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          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

      - name: Post plan to PR
        uses: actions/github-script@v7
        if: github.event_name == 'pull_request'
        with:
          script: |
            const output = `#### Terraform Plan
            \`\`\`
            ${{ steps.plan.outputs.stdout }}
            \`\`\`
            *Triggered by @${{ github.actor }}*`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            });
```

**Verify**: Create a PR that modifies `terraform/` → see plan output posted as a PR comment.

### 2. Create the apply workflow for main branch

Create `.github/workflows/terraform-apply.yml` to apply on merge to main with environment protection. [src1]

```yaml
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

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          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 to main → apply job waits for environment approval → approve → resources created.

### 3. Configure OIDC federation for AWS

Set up OIDC identity provider in AWS and create an IAM role that GitHub Actions can assume. [src6]

```bash
# Create OIDC provider in AWS (one-time)
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

# IAM role trust policy (trust-policy.json)
{
  "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 the workflow shows the assumed role ARN.

### 4. Add security scanning with TFLint and tfsec

Run linting and security scanning before the plan step. [src4]

```yaml
  lint:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: terraform
    steps:
      - uses: actions/checkout@v4
      - uses: terraform-linters/setup-tflint@v4
      - run: tflint --init
      - run: tflint --recursive
      - name: Run tfsec
        uses: aquasecurity/tfsec-action@v1.0.3
        with:
          working_directory: terraform
```

**Verify**: Introduce an insecure configuration (e.g., open security group) → tfsec fails the job.

### 5. Set up remote state backend

Configure S3 backend with DynamoDB state locking in your Terraform configuration. [src1]

```hcl
# terraform/backend.tf
terraform {
  required_version = ">= 1.7.0"

  backend "s3" {
    bucket         = "myorg-terraform-state"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}
```

**Verify**: `terraform init` → "Successfully configured the backend" message.

## Code Examples

### Complete two-job workflow (plan on PR, apply on merge)

> Full script: [complete-two-job-workflow-plan-on-pr-apply-on-merg.yml](scripts/complete-two-job-workflow-plan-on-pr-apply-on-merg.yml) (75 lines)

```yaml
# .github/workflows/terraform.yml
# Input:  Terraform code in terraform/ directory
# Output: Infrastructure changes planned (PR) or applied (merge)
name: Terraform CI/CD
on:
# ... (see full script)
```

### Multi-environment workflow with workspaces

> Full script: [multi-environment-workflow-with-workspaces.yml](scripts/multi-environment-workflow-with-workspaces.yml) (35 lines)

```yaml
# .github/workflows/terraform-multi-env.yml
# Input:  Terraform code with workspace-based environments
# Output: Per-environment plan and apply
name: Terraform Multi-Environment
on:
# ... (see full script)
```

## Anti-Patterns

### Wrong: Applying without a saved plan file

```yaml
# ❌ BAD — terraform apply without plan file may apply different changes than reviewed
- run: terraform plan
- run: terraform apply -auto-approve
```

### Correct: Using saved plan file for apply

```yaml
# ✅ GOOD — plan -out saves exact changes, apply uses that exact plan
- run: terraform plan -out=tfplan
- run: terraform apply tfplan
```

### Wrong: Using long-lived access keys

```yaml
# ❌ BAD — static access keys can leak and grant permanent access
env:
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
```

### Correct: Using OIDC federation

```yaml
# ✅ 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
```

### Wrong: Auto-applying on pull requests

```yaml
# ❌ BAD — PRs from forks could destroy infrastructure
on: pull_request
jobs:
  apply:
    steps:
      - run: terraform apply -auto-approve
```

### Correct: Plan on PR, apply on merge with approval

```yaml
# ✅ GOOD — plan on PR for review, apply on merge with environment protection
  plan:
    if: github.event_name == 'pull_request'
    # ... terraform plan only

  apply:
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production
    # ... terraform apply with approval gate
```

## Common Pitfalls

- **Terraform version mismatch**: CI uses different version than local. Fix: pin version in `hashicorp/setup-terraform` AND `required_version` in terraform block. [src2]
- **State lock timeout**: Concurrent runs contend for state lock. Fix: enable `concurrency: group: terraform` in workflow to serialize runs. [src1]
- **Plan output too large for PR comment**: GitHub has 65536 character limit for comments. Fix: truncate output or link to workflow run. [src3]
- **OIDC token not available**: `id-token: write` permission missing. Fix: add `permissions: id-token: write` at job level. [src6]
- **Backend initialization failure**: Missing S3 bucket or DynamoDB table. Fix: create backend resources manually or with a separate bootstrap Terraform config. [src1]
- **Sensitive values in plan output**: Terraform may show secrets in plan. Fix: mark variables as `sensitive = true` in variable declarations. [src4]

## Diagnostic Commands

```bash
# Check Terraform version
terraform version

# Verify state backend configuration
terraform init -backend=true

# List all managed resources
terraform state list

# Show current workspace
terraform workspace show

# Check for formatting issues
terraform fmt -check -recursive -diff

# Validate configuration
terraform validate

# Show providers and their versions
terraform providers

# Check OIDC caller identity (AWS)
aws sts get-caller-identity
```

## Version History & Compatibility

| Version | Status | Breaking Changes | Migration Notes |
|---|---|---|---|
| Terraform 1.9 | Current | Removed module variable validation experiment | Stable, recommended |
| Terraform 1.7-1.8 | Active | `removed` block for state-only removals | — |
| Terraform 1.6 | Active | Built-in test framework | Add `terraform test` to CI |
| Terraform 1.5 | Maintenance | `import` block for config-driven imports | — |
| hashicorp/setup-terraform@v3 | Current | Node.js 20 runtime | Update from @v2 |
| OpenTofu 1.6+ | Alternative | Open-source fork | Compatible with Terraform 1.6 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 hosted on GitHub | Project on GitLab | GitLab CI Terraform integration |
| Need PR-based review for infra changes | Need continuous reconciliation | Terraform Cloud or Spacelift |
| Simple to moderate infra complexity | 100+ modules with complex dependencies | Terragrunt or Terraform Cloud |
| OIDC-capable cloud provider | On-premises infrastructure only | Jenkins or self-hosted runners |

## Important Caveats

- `terraform plan` output can contain sensitive data (passwords, API keys). Mark variables as `sensitive = true` and review plan output before posting to PR comments.
- GitHub Actions cache does not cache Terraform providers by default. Use `actions/cache` for `.terraform/providers/` to speed up `terraform init`.
- Concurrent `terraform apply` runs against the same state will fail with lock errors. Use `concurrency` groups or `max-parallel: 1`.
- OIDC trust policies should restrict to specific repos and branches -- overly broad conditions (`repo:*`) are a security risk.
- `hashicorp/setup-terraform` wraps `terraform` output for GitHub Actions. The `terraform_wrapper: false` input disables this if it interferes with output parsing.

## Related Units

- [Terraform State Conflict Debugging](/software/debugging/terraform-state-conflicts/2026)
- [GitHub Actions: Docker Build and Push](/software/devops/github-actions-docker/2026)
- [GitLab CI: Basic Pipeline](/software/devops/gitlab-ci-pipeline/2026)
