| | | |

Terraform GitHub Actions Guide 2025: Secure CI/CD for IaC

Terraform GitHub Actions is the practice of running Terraform commands within GitHub Actions workflows to automate infrastructure provisioning. It enables CI/CD for Infrastructure as Code, allowing teams to run init, plan, and apply directly in pull requests with built-in security, approvals, and auditability.

Quick Formula:
Push/PR ➜ Workflow ➜ Terraform init ➜ Plan ➜ Review/Approval ➜ Apply ➜ Infrastructure Updated


Introduction & Why Combine Terraform + GitHub Actions

As a senior DevOps engineer who’s built dozens of IaC pipelines, I can confidently say that combining Terraform with GitHub Actions creates one of the most powerful automation stacks available today. GitHub Actions provides native integration with your code repository, eliminating the need for external CI/CD tools while maintaining professional-grade features.

Why GitHub Actions is the Natural Choice for Terraform

GitHub Actions excels for Terraform pipelines because it’s already where your code lives. No webhook configurations, no authentication gymnastics between systems, and no context switching. When a developer opens a pull request modifying Terraform files, the workflow triggers automatically, runs a plan, and comments the results directly in the PR. This tight integration accelerates feedback loops dramatically.

The platform offers several advantages specifically valuable for Infrastructure as Code:

  • Native GitHub integration: Pull request comments, status checks, and deployment environments work seamlessly
  • Secrets management: GitHub Secrets and OIDC integration provide secure credential handling without storing long-lived tokens
  • Cost-effective: 2,000 free minutes per month for private repositories, unlimited for public repos
  • Matrix builds: Run Terraform across multiple modules, environments, or cloud providers simultaneously
  • Reusable workflows: DRY principles for your CI/CD code just like your infrastructure code

The Value of IaC + CI/CD Integration

Traditional infrastructure provisioning involved manual console clicks, inconsistent configurations, and zero audit trails. Terraform brought declarative infrastructure, but manual Terraform runs still create bottlenecks and human error risks. CI/CD integration for IaC delivers:

Automation: Every infrastructure change goes through the same validated pipeline. No more “it works on my machine” scenarios where one engineer’s local Terraform version differs from production.

Repeatability: The same workflow executes identically across development, staging, and production. Your infrastructure becomes as predictable as application deployments.

Governance: Pull request reviews for infrastructure changes create an approval paper trail. Automated compliance checks run before any resource is created. GitHub Actions logs provide complete auditability of who changed what and when.

Common Use Cases

Teams leverage Terraform GitHub Actions workflows for:

  1. Provisioning new infrastructure: Deploying VPCs, Kubernetes clusters, databases, and networking resources automatically when code merges
  2. Validating pull requests: Running terraform plan on every PR to catch errors before they reach main branch
  3. Compliance checking: Integrating policy-as-code tools like Open Policy Agent or Sentinel to enforce security standards
  4. Drift detection: Scheduled workflows that run plans to identify manual changes made outside Terraform
  5. Multi-environment promotion: Using workflow dispatch or branch-based triggers to promote infrastructure changes through environments

What You’ll Gain from This Guide

This article provides production-ready YAML workflows, security best practices, and real-world patterns from teams running Terraform at scale. You’ll learn how to build pipelines that are secure, scalable, and maintainable. Whether you’re starting fresh or refactoring existing workflows, you’ll find copy-paste examples and architectural guidance that saves weeks of trial and error.


Foundations: Basic Workflow Anatomy

Every Terraform GitHub Actions workflow follows a standard pattern. Understanding these building blocks is essential before tackling advanced scenarios.

Standard Workflow Steps

A minimal Terraform workflow consists of five core steps:

  1. Checkout: Clone the repository code
  2. Setup Terraform: Install the Terraform CLI binary
  3. Init: Initialize the backend and download providers
  4. Plan: Generate an execution plan showing proposed changes
  5. Apply: Execute the plan to create/modify/destroy resources

Here’s the foundational workflow structure:

name: Terraform CI/CD

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  terraform:
    runs-on: ubuntu-latest
    
    steps:
      # Step 1: Checkout repository
      - name: Checkout code
        uses: actions/checkout@v4
      
      # Step 2: Setup Terraform CLI
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0
      
      # Step 3: Initialize Terraform
      - name: Terraform Init
        run: terraform init
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
      
      # Step 4: Plan infrastructure changes
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        continue-on-error: true
      
      # Step 5: Apply changes (only on main branch)
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve tfplan

Using Marketplace Actions

The GitHub Actions marketplace provides specialized Terraform actions that add helpful features. The most popular is hashicorp/setup-terraform, which installs Terraform and configures wrapper scripts that capture outputs.

Another excellent option is the dflook/terraform-* action family:

- name: Terraform Plan with dflook
  uses: dflook/terraform-plan@v1
  with:
    path: infrastructure/
    backend_config: |
      bucket=my-terraform-state
      key=prod/terraform.tfstate
      region=us-east-1

These marketplace actions provide automatic PR commenting, plan artifact management, and simplified backend configuration.

Sample Outputs

When terraform plan executes in your workflow, you’ll see output like this in the Actions log:

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create
  ~ update in-place
  - destroy

Terraform will perform the following actions:

  # aws_instance.web_server will be created
  + resource "aws_instance" "web_server" {
      + ami                          = "ami-0c55b159cbfafe1f0"
      + instance_type                = "t3.micro"
      + key_name                     = "production-key"
      + vpc_security_group_ids       = [
          + "sg-0abc123def456",
        ]
    }

Plan: 1 to add, 0 to change, 0 to destroy.

After approval and apply:

aws_instance.web_server: Creating...
aws_instance.web_server: Still creating... [10s elapsed]
aws_instance.web_server: Creation complete after 32s [id=i-0a1b2c3d4e5f6g7h8]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:

instance_ip = "54.123.45.67"


Secure Pipeline Design: Secrets, State & Credentials

Security is non-negotiable in IaC pipelines. A compromised workflow could provision rogue infrastructure or expose production credentials.

Backend Configuration Handling

Terraform requires state storage in a remote backend for team collaboration. The three most common backends are AWS S3, Google Cloud Storage, and Azure Blob Storage. Configure backends securely by avoiding hardcoded credentials:

# backend.tf
terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "production/vpc/terraform.tfstate"
    region         = "us-west-2"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}

In your workflow, pass backend configuration via environment variables:

- name: Terraform Init with Secure Backend
  run: terraform init
  env:
    AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
    AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
    TF_CLI_ARGS_init: >-
      -backend-config="bucket=${{ secrets.TF_STATE_BUCKET }}"
      -backend-config="dynamodb_table=${{ secrets.TF_LOCK_TABLE }}"

Managing Terraform Variables & Secrets

GitHub Secrets provide encrypted storage for sensitive values. Access them in workflows using the ${{ secrets.SECRET_NAME }} syntax. For Terraform variables, you have three options:

Option 1: Environment Variables

- name: Terraform Apply
  run: terraform apply -auto-approve
  env:
    TF_VAR_database_password: ${{ secrets.DB_PASSWORD }}
    TF_VAR_api_key: ${{ secrets.API_KEY }}

Option 2: Variable Files

- name: Create terraform.tfvars
  run: |
    cat << EOF > terraform.tfvars
    database_password = "${{ secrets.DB_PASSWORD }}"
    api_key = "${{ secrets.API_KEY }}"
    EOF

Option 3: CLI Arguments

- name: Terraform Plan with Variables
  run: |
    terraform plan \
      -var="database_password=${{ secrets.DB_PASSWORD }}" \
      -var="api_key=${{ secrets.API_KEY }}"

OpenID Connect eliminates long-lived credentials by using short-lived tokens. GitHub Actions can assume cloud provider roles dynamically. Here are examples for all three major cloud providers:

AWS OIDC Configuration:

name: Terraform with AWS OIDC

permissions:
  id-token: write  # Required for OIDC
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      # Configure AWS credentials via OIDC
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsRole
          role-session-name: terraform-deployment
          aws-region: us-east-1
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      
      - name: Terraform Init
        run: terraform init
      
      - name: Terraform Apply
        run: terraform apply -auto-approve

The AWS IAM role must trust GitHub’s OIDC provider:

{
  "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",
          "token.actions.githubusercontent.com:sub": "repo:orgname/reponame:ref:refs/heads/main"
        }
      }
    }
  ]
}

Azure OIDC Configuration:

name: Terraform with Azure OIDC

permissions:
  id-token: write
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      # Configure Azure credentials via OIDC
      - name: Azure Login with OIDC
        uses: azure/login@v1
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      
      - name: Terraform Init
        run: terraform init
        env:
          ARM_USE_OIDC: true
          ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      
      - name: Terraform Apply
        run: terraform apply -auto-approve
        env:
          ARM_USE_OIDC: true
          ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
          ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
          ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}

Create the Azure federated credential using Azure CLI:

az ad app federated-credential create \
  --id <APPLICATION_OBJECT_ID> \
  --parameters '{
    "name": "GitHubActionsOIDC",
    "issuer": "https://token.actions.githubusercontent.com",
    "subject": "repo:orgname/reponame:ref:refs/heads/main",
    "audiences": ["api://AzureADTokenExchange"]
  }'

Google Cloud (GCP) OIDC Configuration:

name: Terraform with GCP OIDC

permissions:
  id-token: write
  contents: read

jobs:
  terraform:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      # Configure GCP credentials via OIDC
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider'
          service_account: 'terraform-sa@project-id.iam.gserviceaccount.com'
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      
      - name: Terraform Init
        run: terraform init
      
      - name: Terraform Apply
        run: terraform apply -auto-approve

Create the GCP Workload Identity Pool and provider:

# Create Workload Identity Pool
gcloud iam workload-identity-pools create "github-pool" \
  --project="project-id" \
  --location="global" \
  --display-name="GitHub Actions Pool"

# Create Workload Identity Provider
gcloud iam workload-identity-pools providers create-oidc "github-provider" \
  --project="project-id" \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --display-name="GitHub Provider" \
  --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository" \
  --issuer-uri="https://token.actions.githubusercontent.com"

# Grant service account access
gcloud iam service-accounts add-iam-policy-binding "terraform-sa@project-id.iam.gserviceaccount.com" \
  --project="project-id" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/github-pool/attribute.repository/orgname/reponame"

Least Privilege & Token Rotation

Apply the principle of least privilege to workflow permissions:

permissions:
  contents: read       # Read repository code
  pull-requests: write # Comment on PRs
  id-token: write      # OIDC token generation
  # Explicitly deny all other permissions

For static credentials, rotate them every 90 days. Use separate service accounts for dev, staging, and production with environment-specific permissions. Never reuse production credentials in development workflows.


Plan / Apply Strategy, Plan Validation & Freshness

The most critical architectural decision in Terraform CI/CD is how you handle plan and apply operations. A poor strategy leads to stale plans, inconsistent state, and dangerous applies.

The gold standard approach separates plan and apply into distinct jobs with artifact passing:

name: Terraform Plan and Apply

on:
  pull_request:
    branches: [main]
  push:
    branches: [main]

jobs:
  plan:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0
      
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
      
      - name: Terraform Plan
        id: plan
        run: |
          terraform plan -out=tfplan.binary
          terraform show -json tfplan.binary > tfplan.json
      
      # Upload the binary plan for apply job
      - name: Upload Plan Artifact
        uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: tfplan.binary
          retention-days: 5
      
      # Comment sanitized plan summary on PR (avoids secret leakage)
      - name: Comment Sanitized Plan on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const planJson = JSON.parse(fs.readFileSync('tfplan.json', 'utf8'));
            
            // Extract safe summary data
            const resourceChanges = planJson.resource_changes || [];
            const toAdd = resourceChanges.filter(r => r.change.actions.includes('create')).length;
            const toChange = resourceChanges.filter(r => r.change.actions.includes('update')).length;
            const toDestroy = resourceChanges.filter(r => r.change.actions.includes('delete')).length;
            
            // Build resource type summary
            const resourceTypes = {};
            resourceChanges.forEach(r => {
              const type = r.type;
              if (!resourceTypes[type]) resourceTypes[type] = { add: 0, change: 0, destroy: 0 };
              if (r.change.actions.includes('create')) resourceTypes[type].add++;
              if (r.change.actions.includes('update')) resourceTypes[type].change++;
              if (r.change.actions.includes('delete')) resourceTypes[type].destroy++;
            });
            
            // Format resource type table
            let typeTable = '| Resource Type | Add | Change | Destroy |\n|---------------|-----|--------|---------|';
            Object.keys(resourceTypes).sort().forEach(type => {
              const counts = resourceTypes[type];
              typeTable += `\n| ${type} | ${counts.add} | ${counts.change} | ${counts.destroy} |`;
            });
            
            // List affected resources (names only, no values)
            const affectedResources = resourceChanges.map(r => 
              `- **${r.address}** (${r.change.actions.join(', ')})`
            ).join('\n');
            
            const output = `#### Terraform Plan Summary 📊
            
**Plan:** ${toAdd} to add, ${toChange} to change, ${toDestroy} to destroy

<details>
<summary>Resource Type Summary</summary>

${typeTable}

</details>

<details>
<summary>Affected Resources</summary>

${affectedResources}

</details>

**Note:** Sensitive values are not displayed. Review full plan in workflow logs.

*Pushed by: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*`;
            
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: output
            });

  apply:
    needs: plan
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment: production  # Requires approval
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0
      
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
      
      # Download the exact plan from the plan job
      - name: Download Plan Artifact
        uses: actions/download-artifact@v4
        with:
          name: tfplan
      
      - name: Terraform Apply
        run: terraform apply tfplan.binary

Plan Validation & Freshness

Stale plans are dangerous. A plan generated against state version N may be invalid when state reaches version N+5. Implement freshness checks:

- name: Validate Plan Freshness
  run: |
    PLAN_TIME=$(stat -c %Y tfplan.binary)
    CURRENT_TIME=$(date +%s)
    AGE=$((CURRENT_TIME - PLAN_TIME))
    MAX_AGE=3600  # 1 hour
    
    if [ $AGE -gt $MAX_AGE ]; then
      echo "Plan is stale (${AGE}s old). Regenerate plan."
      exit 1
    fi

Encrypting Plan Files

Plan files contain sensitive data. Encrypt them before uploading as artifacts:

- name: Encrypt Plan File
  run: |
    openssl enc -aes-256-cbc -salt \
      -in tfplan.binary \
      -out tfplan.enc \
      -pass pass:${{ secrets.PLAN_ENCRYPTION_KEY }}

- name: Upload Encrypted Plan
  uses: actions/upload-artifact@v4
  with:
    name: tfplan-encrypted
    path: tfplan.enc

Then decrypt before apply:

- name: Decrypt Plan File
  run: |
    openssl enc -aes-256-cbc -d \
      -in tfplan.enc \
      -out tfplan.binary \
      -pass pass:${{ secrets.PLAN_ENCRYPTION_KEY }}

Drift Detection Strategy

Scheduled workflows detect configuration drift caused by manual changes:

name: Drift Detection

on:
  schedule:
    - cron: '0 8 * * 1-5'  # Weekdays at 8 AM UTC
  workflow_dispatch:

jobs:
  detect-drift:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
      
      - name: Detect Drift
        id: drift
        run: |
          terraform plan -detailed-exitcode > drift.txt 2>&1
          EXIT_CODE=$?
          
          if [ $EXIT_CODE -eq 2 ]; then
            echo "drift_detected=true" >> $GITHUB_OUTPUT
          fi
        continue-on-error: true
      
      - name: Create Issue on Drift
        if: steps.drift.outputs.drift_detected == 'true'
        uses: actions/github-script@v7
        with:
          script: |
            const fs = require('fs');
            const drift = fs.readFileSync('drift.txt', 'utf8');
            
            github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: '🚨 Infrastructure Drift Detected',
              body: `Drift detected in production infrastructure.\n\n\`\`\`\n${drift}\n\`\`\``
            });


Multi-Module / Monorepo Workflows

Large organizations often structure Terraform code across multiple modules or maintain monorepos with infrastructure for multiple services. GitHub Actions handles these scenarios elegantly with matrix builds and reusable workflows.

Structuring Monorepo Workflows

Consider a repository structure like:

infrastructure/
├── modules/
│   ├── networking/
│   ├── compute/
│   └── database/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   └── backend.tf
│   ├── staging/
│   └── production/
└── .github/
    └── workflows/
        ├── terraform-reusable.yml
        └── terraform-all-envs.yml

Reusable Workflow Template

Create a reusable workflow that can be called for each environment:

# .github/workflows/terraform-reusable.yml
name: Reusable Terraform Workflow

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      working-directory:
        required: true
        type: string
      terraform-version:
        required: false
        type: string
        default: '1.7.0'
    secrets:
      AWS_ROLE_ARN:
        required: true

jobs:
  terraform:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    
    defaults:
      run:
        working-directory: ${{ inputs.working-directory }}
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ inputs.terraform-version }}
      
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
      
      - name: Terraform Validate
        run: terraform validate
      
      - name: Terraform Plan
        id: plan
        run: terraform plan -no-color -out=tfplan
        continue-on-error: true
      
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve tfplan

Matrix Strategy for Multiple Modules

Call the reusable workflow for multiple environments simultaneously:

# .github/workflows/terraform-all-envs.yml
name: Terraform All Environments

on:
  pull_request:
    paths:
      - 'infrastructure/**'
  push:
    branches: [main]
    paths:
      - 'infrastructure/**'

jobs:
  matrix-terraform:
    strategy:
      matrix:
        environment:
          - name: dev
            directory: infrastructure/environments/dev
            role: arn:aws:iam::111111111111:role/TerraformDevRole
          - name: staging
            directory: infrastructure/environments/staging
            role: arn:aws:iam::222222222222:role/TerraformStagingRole
          - name: production
            directory: infrastructure/environments/production
            role: arn:aws:iam::333333333333:role/TerraformProdRole
    
    uses: ./.github/workflows/terraform-reusable.yml
    with:
      environment: ${{ matrix.environment.name }}
      working-directory: ${{ matrix.environment.directory }}
    secrets:
      AWS_ROLE_ARN: ${{ matrix.environment.role }}

Handling Module Dependencies

When modules depend on each other, use job dependencies and output passing:

jobs:
  networking:
    uses: ./.github/workflows/terraform-reusable.yml
    with:
      environment: production
      working-directory: infrastructure/modules/networking
    secrets:
      AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
  
  compute:
    needs: networking  # Wait for networking to complete
    uses: ./.github/workflows/terraform-reusable.yml
    with:
      environment: production
      working-directory: infrastructure/modules/compute
    secrets:
      AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
  
  database:
    needs: networking  # Depends on networking only
    uses: ./.github/workflows/terraform-reusable.yml
    with:
      environment: production
      working-directory: infrastructure/modules/database
    secrets:
      AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}

Path-Based Triggering

Optimize workflow runs by triggering only affected modules:

on:
  pull_request:
    paths:
      - 'infrastructure/modules/networking/**'
      - 'infrastructure/environments/*/networking.tf'

jobs:
  networking-only:
    # Only runs when networking-related files change
    uses: ./.github/workflows/terraform-reusable.yml


Approval Gates & Manual Intervention

Fully automated infrastructure deployments carry risk. Production changes should require human review and approval. GitHub Actions provides several mechanisms for gating applies.

GitHub Environments with Required Reviewers

Environments are the primary approval mechanism in GitHub Actions:

name: Terraform with Approvals

on:
  push:
    branches: [main]

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      - name: Terraform Init
        run: terraform init
      - name: Terraform Plan
        run: terraform plan -out=tfplan
      - name: Upload Plan
        uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: tfplan

  apply:
    needs: plan
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://console.aws.amazon.com/ec2
    
    steps:
      - uses: actions/checkout@v4
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      - name: Terraform Init
        run: terraform init
      - name: Download Plan
        uses: actions/download-artifact@v4
        with:
          name: tfplan
      - name: Terraform Apply
        run: terraform apply tfplan

Configure the production environment in your repository settings (Settings → Environments) with:

  • Required reviewers (up to 6 people or teams)
  • Wait timer (delay before deployment can proceed)
  • Deployment branches (restrict which branches can deploy)

Workflow Dispatch for Manual Triggers

For maximum control, use workflow_dispatch to require manual approval before any apply:

name: Manual Terraform Apply

on:
  workflow_dispatch:
    inputs:
      environment:
        description: 'Target environment'
        required: true
        type: choice
        options:
          - dev
          - staging
          - production
      confirm_apply:
        description: 'Type APPLY to confirm'
        required: true
        type: string

jobs:
  apply:
    runs-on: ubuntu-latest
    if: inputs.confirm_apply == 'APPLY'
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
      
      - name: Terraform Init
        run: terraform init
        working-directory: environments/${{ inputs.environment }}
      
      - name: Terraform Apply
        run: terraform apply -auto-approve
        working-directory: environments/${{ inputs.environment }}

Tradeoffs: Automated vs Gated Pipelines

Fully Automated:

  • Pros: Fast feedback loops, no bottlenecks, ideal for development environments
  • Cons: Higher risk, requires excellent test coverage, potential for unreviewed changes to reach production

Human-Gated:

  • Pros: Safety net for critical infrastructure, enforces review process, meets compliance requirements
  • Cons: Slower deployments, requires someone on-call to approve, can create bottlenecks

Recommended Hybrid Approach:

  • Development: Fully automated
  • Staging: Automated with post-deployment testing
  • Production: Required approvals + wait timer

Multi-Stage Approval YAML

Implement progressive approvals for sensitive changes:

jobs:
  plan:
    runs-on: ubuntu-latest
    # ... plan steps ...

  security-review:
    needs: plan
    runs-on: ubuntu-latest
    environment: security-review  # Security team approval
    steps:
      - run: echo "Security team approved"

  apply:
    needs: security-review
    runs-on: ubuntu-latest
    environment: production  # Operations team approval
    steps:
      - # ... apply steps ...


Scalability, Locking & Concurrency Control

As teams grow and PR velocity increases, concurrent Terraform operations become problematic. Multiple applies running simultaneously can corrupt state, cause race conditions, or create conflicting resource modifications.

Remote State Locking

Terraform’s remote backends provide native locking to prevent concurrent operations. For AWS S3 backends, DynamoDB provides the locking mechanism:

terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-west-2"
    dynamodb_table = "terraform-state-lock"  # Locking table
    encrypt        = true
  }
}

The DynamoDB table requires a simple schema:

resource "aws_dynamodb_table" "terraform_lock" {
  name           = "terraform-state-lock"
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

When Terraform runs, it acquires a lock on the state file. If another process attempts to run concurrently, it receives:

Error: Error acquiring the state lock

Error message: ConditionalCheckFailedException: The conditional request failed
Lock Info:
  ID:        a1b2c3d4-5678-90ab-cdef-1234567890ab
  Path:      production/terraform.tfstate
  Operation: OperationTypeApply
  Who:       github-actions@runner-1234
  Version:   1.7.0
  Created:   2025-03-15 14:32:10.123456789 +0000 UTC

GitHub Actions Concurrency Groups

GitHub Actions provides workflow-level concurrency control. Use concurrency groups to serialize deployments:

name: Terraform Apply

on:
  push:
    branches: [main]

concurrency:
  group: terraform-production-apply
  cancel-in-progress: false  # Don't cancel running applies

jobs:
  apply:
    runs-on: ubuntu-latest
    steps:
      # ... terraform steps ...

This ensures only one apply runs at a time for the production environment, even if multiple commits are pushed rapidly.

Environment-Specific Concurrency

Use dynamic concurrency groups for multi-environment workflows:

concurrency:
  group: terraform-${{ inputs.environment }}-apply
  cancel-in-progress: false

jobs:
  apply:
    runs-on: ubuntu-latest
    steps:
      - name: Terraform Apply
        run: terraform apply -auto-approve
        working-directory: environments/${{ inputs.environment }}

This allows dev and staging applies to run concurrently while serializing applies within each environment.

Handling Concurrent Pull Requests

For plan operations on pull requests, allow concurrent runs but differentiate them:

name: Terraform Plan

on:
  pull_request:

concurrency:
  group: terraform-plan-${{ github.event.pull_request.number }}
  cancel-in-progress: true  # Cancel outdated plans when new commits are pushed

jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      # ... plan steps ...

The cancel-in-progress: true setting cancels obsolete plan jobs when developers push new commits to the PR, saving CI/CD minutes.

Queue-Based Apply Strategy

For high-volume environments, implement a queue mechanism using workflow artifacts:

jobs:
  queue-apply:
    runs-on: ubuntu-latest
    steps:
      - name: Add to Apply Queue
        run: |
          echo "${{ github.sha }}" >> apply-queue.txt
          echo "Queued apply for commit ${{ github.sha }}"
      
      - name: Upload Queue
        uses: actions/upload-artifact@v4
        with:
          name: apply-queue
          path: apply-queue.txt
  
  process-queue:
    needs: queue-apply
    runs-on: ubuntu-latest
    concurrency:
      group: terraform-apply-processor
      cancel-in-progress: false
    steps:
      - name: Download Queue
        uses: actions/download-artifact@v4
        with:
          name: apply-queue
      
      - name: Process Applies
        run: |
          while read commit; do
            echo "Processing apply for $commit"
            # Apply logic here
          done < apply-queue.txt


Error Handling, Rollbacks & Retry Logic

Infrastructure deployments fail. Networks timeout, API rate limits hit, resources reach quota limits. Robust pipelines handle failures gracefully without leaving infrastructure in inconsistent states.

Detecting Partial Failures

Terraform’s exit codes indicate operation status:

  • 0: Successful, no changes
  • 1: Error occurred
  • 2: Successful, changes made (for plan with -detailed-exitcode)

Capture these in your workflow:

- name: Terraform Apply with Error Detection
  id: apply
  run: |
    terraform apply -auto-approve tfplan
    echo "apply_status=success" >> $GITHUB_OUTPUT
  continue-on-error: true

- name: Handle Apply Failure
  if: steps.apply.outcome == 'failure'
  run: |
    echo "Apply failed. Capturing state for analysis..."
    terraform show > failed-state.txt
    
- name: Upload Failed State
  if: steps.apply.outcome == 'failure'
  uses: actions/upload-artifact@v4
  with:
    name: failed-state-${{ github.run_id }}
    path: failed-state.txt

- name: Create Incident Issue
  if: steps.apply.outcome == 'failure'
  uses: actions/github-script@v7
  with:
    script: |
      github.rest.issues.create({
        owner: context.repo.owner,
        repo: context.repo.repo,
        title: '🔥 Terraform Apply Failed - Run ${{ github.run_id }}',
        labels: ['terraform', 'incident'],
        body: 'Terraform apply failed. Check artifacts and logs.'
      });

Idempotent Apply Operations

Terraform is inherently idempotent, but ensure your workflows support safe reruns:

- name: Idempotent Apply with State Refresh
  run: |
    # Always refresh state before apply
    terraform refresh
    
    # Apply with refresh to catch any external changes
    terraform apply -auto-approve -refresh=true tfplan

Rollback Strategies

Terraform doesn’t provide native rollback, but you can implement several patterns:

Strategy 1: State Backup and Restore

- name: Backup Current State
  run: |
    terraform state pull > state-backup-$(date +%s).json
    
- name: Upload State Backup
  uses: actions/upload-artifact@v4
  with:
    name: state-backup-${{ github.sha }}
    path: state-backup-*.json
    retention-days: 30

- name: Terraform Apply
  id: apply
  run: terraform apply -auto-approve tfplan
  continue-on-error: true

- name: Rollback on Failure
  if: steps.apply.outcome == 'failure'
  run: |
    echo "Attempting rollback to previous state..."
    # Download previous good state
    # terraform state push previous-state.json
    echo "Manual intervention required for rollback"

Strategy 2: Destroy and Recreate

For non-production environments:

- name: Rollback via Destroy
  if: steps.apply.outcome == 'failure'
  run: |
    terraform destroy -auto-approve
    git checkout HEAD~1  # Previous commit
    terraform init
    terraform apply -auto-approve

Strategy 3: Git Revert and Reapply

- name: Automated Revert on Failure
  if: steps.apply.outcome == 'failure'
  run: |
    git revert ${{ github.sha }} --no-edit
    git push
    # This triggers a new workflow run with reverted code

Retry Logic with Exponential Backoff

Implement retries for transient failures:

- name: Terraform Apply with Retry
  uses: nick-invision/retry@v2
  with:
    timeout_minutes: 30
    max_attempts: 3
    retry_wait_seconds: 60
    exponential_backoff: true
    command: terraform apply -auto-approve tfplan
    
- name: Manual Retry with Backoff Script
  run: |
    #!/bin/bash
    max_attempts=3
    attempt=1
    
    while [ $attempt -le $max_attempts ]; do
      echo "Apply attempt $attempt of $max_attempts"
      
      if terraform apply -auto-approve tfplan; then
        echo "Apply succeeded"
        exit 0
      fi
      
      if [ $attempt -lt $max_attempts ]; then
        wait_time=$((2 ** attempt * 30))  # Exponential backoff
        echo "Apply failed. Retrying in ${wait_time}s..."
        sleep $wait_time
      fi
      
      attempt=$((attempt + 1))
    done
    
    echo "Apply failed after $max_attempts attempts"
    exit 1

Resource-Specific Error Handling

Handle specific resource failures differently:

- name: Terraform Apply with Resource Targeting
  id: full_apply
  run: terraform apply -auto-approve tfplan
  continue-on-error: true

- name: Retry Failed Resources Only
  if: steps.full_apply.outcome == 'failure'
  run: |
    # Extract failed resources from logs
    failed_resources=$(terraform show -json | jq -r '.resource_changes[] | select(.change.actions[] == "create" or .change.actions[] == "update") | .address')
    
    for resource in $failed_resources; do
      echo "Retrying resource: $resource"
      terraform apply -target="$resource" -auto-approve
      sleep 10  # Brief pause between resources
    done


Observability & Auditability

Production infrastructure pipelines require comprehensive logging, monitoring, and audit trails. GitHub Actions provides several mechanisms for observability.

Comprehensive Terraform Logging

Enable verbose Terraform logging for troubleshooting:

- name: Terraform Apply with Debug Logging
  run: terraform apply -auto-approve tfplan
  env:
    TF_LOG: DEBUG
    TF_LOG_PATH: terraform-debug.log

- name: Upload Debug Logs
  if: always()  # Upload even on failure
  uses: actions/upload-artifact@v4
  with:
    name: terraform-logs-${{ github.run_id }}
    path: |
      terraform-debug.log
      *.tfstate.backup
    retention-days: 90  # Retain for compliance

Structured Output for Analysis

Generate machine-readable outputs:

- name: Terraform Plan JSON Output
  run: |
    terraform plan -out=tfplan
    terraform show -json tfplan > plan-output.json

- name: Analyze Plan Changes
  run: |
    # Count resources by action
    cat plan-output.json | jq -r '
      .resource_changes[] | 
      .change.actions[] 
    ' | sort | uniq -c
    
    # Extract sensitive changes
    cat plan-output.json | jq '
      .resource_changes[] | 
      select(.change.actions[] | contains("delete")) |
      {resource: .address, action: .change.actions}
    ' > deletion-report.json

- name: Upload Analysis
  uses: actions/upload-artifact@v4
  with:
    name: plan-analysis
    path: |
      plan-output.json
      deletion-report.json

Audit Trail Creation

Build a comprehensive audit trail:

- name: Generate Audit Entry
  run: |
    cat > audit-entry.json &lt;&lt; EOF
    {
      "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
      "workflow_run_id": "${{ github.run_id }}",
      "workflow_run_number": "${{ github.run_number }}",
      "triggered_by": "${{ github.actor }}",
      "event": "${{ github.event_name }}",
      "ref": "${{ github.ref }}",
      "sha": "${{ github.sha }}",
      "repository": "${{ github.repository }}",
      "terraform_version": "$(terraform version -json | jq -r .terraform_version)",
      "environment": "production"
    }
    EOF

- name: Send Audit to External System
  run: |
    curl -X POST https://audit-api.company.com/terraform-events \
      -H "Authorization: Bearer ${{ secrets.AUDIT_API_TOKEN }}" \
      -H "Content-Type: application/json" \
      -d @audit-entry.json

Real-Time Notifications

Integrate with communication platforms:

- name: Notify Slack on Apply
  if: github.ref == 'refs/heads/main'
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "🚀 Terraform Apply Started",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Terraform Apply - Production*\n\n*Triggered by:* ${{ github.actor }}\n*Commit:* <${{ github.event.head_commit.url }}|${{ github.sha }}>\n*Status:* In Progress"
            }
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

- name: Notify on Completion
  if: always()
  uses: slackapi/slack-github-action@v1
  with:
    payload: |
      {
        "text": "Terraform Apply ${{ job.status }}",
        "blocks": [
          {
            "type": "section",
            "text": {
              "type": "mrkdwn",
              "text": "*Status:* ${{ job.status }}\n*Duration:* ${{ steps.apply.outputs.duration }}\n*Resources:* ${{ steps.apply.outputs.resources_changed }} changed"
            }
          }
        ]
      }
  env:
    SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}

Monitoring Pipeline Health

Track workflow metrics over time:

- name: Record Workflow Metrics
  if: always()
  run: |
    end_time=$(date +%s)
    start_time=${{ steps.start.outputs.timestamp }}
    duration=$((end_time - start_time))
    
    cat > metrics.json << EOF
    {
      "workflow": "terraform-apply",
      "status": "${{ job.status }}",
      "duration_seconds": $duration,
      "resources_changed": $(terraform show -json | jq '.resource_changes | length'),
      "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
    }
    EOF
    
    # Send to monitoring system
    curl -X POST https://metrics.company.com/api/v1/metrics \
      -H "Content-Type: application/json" \
      -d @metrics.json

Sample Log Output

A well-instrumented workflow produces logs like:

2025-03-15T14:32:10Z [INFO] Terraform Apply Starting
2025-03-15T14:32:10Z [INFO] Workflow Run ID: 8765432109
2025-03-15T14:32:10Z [INFO] Triggered by: john.doe
2025-03-15T14:32:10Z [INFO] Commit SHA: a1b2c3d4e5f6
2025-03-15T14:32:15Z [INFO] Terraform Version: 1.7.0
2025-03-15T14:32:15Z [INFO] Backend: s3://company-terraform-state/prod
2025-03-15T14:32:18Z [INFO] State Lock Acquired: lock-id-xyz
2025-03-15T14:32:20Z [INFO] Applying plan: 3 to add, 1 to change, 0 to destroy
2025-03-15T14:35:45Z [INFO] Apply Complete: 4 resources affected
2025-03-15T14:35:45Z [INFO] Duration: 215 seconds
2025-03-15T14:35:46Z [INFO] State Lock Released
2025-03-15T14:35:46Z [INFO] Audit entry created: audit-8765432109.json


Lifecycle & Maintenance of the Pipeline

Terraform pipelines require ongoing maintenance. Provider versions evolve, Terraform itself releases new features, and GitHub Actions updates its runner images.

Upgrading Terraform Versions Safely

Never upgrade Terraform in production without testing:

name: Test Terraform Upgrade

on:
  workflow_dispatch:
    inputs:
      new_version:
        description: 'New Terraform version to test'
        required: true
        default: '1.8.0'

jobs:
  test-upgrade:
    runs-on: ubuntu-latest
    environment: staging
    
    steps:
      - uses: actions/checkout@v4
      
      # Test with new version
      - name: Setup New Terraform Version
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ inputs.new_version }}
      
      - name: Terraform Init
        run: terraform init -upgrade  # Upgrade providers
      
      - name: Terraform Validate
        run: terraform validate
      
      - name: Terraform Plan (Dry Run)
        run: terraform plan -out=tfplan
      
      - name: Analyze Plan for Breaking Changes
        run: |
          terraform show -json tfplan > plan.json
          
          # Check for unexpected replacements
          replacements=$(cat plan.json | jq -r '
            [.resource_changes[] | 
             select(.change.actions[] | contains("delete") and contains("create"))] | 
            length
          ')
          
          if [ $replacements -gt 0 ]; then
            echo "WARNING: $replacements resources will be replaced"
            echo "Review carefully before proceeding"
            exit 1
          fi
      
      - name: Create Upgrade Report
        run: |
          echo "# Terraform Upgrade Test Report" > report.md
          echo "" >> report.md
          echo "**New Version:** ${{ inputs.new_version }}" >> report.md
          echo "**Test Date:** $(date)" >> report.md
          echo "" >> report.md
          echo "## Plan Summary" >> report.md
          terraform show tfplan >> report.md
      
      - name: Upload Report
        uses: actions/upload-artifact@v4
        with:
          name: upgrade-report
          path: report.md

Provider Version Pinning

Always pin provider versions to prevent unexpected changes:

terraform {
  required_version = ">= 1.7.0"
  
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40.0"  # Pin to minor version
    }
    kubernetes = {
      source  = "hashicorp/kubernetes"
      version = "~> 2.25.0"
    }
  }
}

Use Dependabot to track provider updates:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "terraform"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 5
    reviewers:
      - "devops-team"
    labels:
      - "terraform"
      - "dependencies"

Keeping GitHub Actions Updated

Pin action versions using commit SHAs for security:

- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
- uses: hashicorp/setup-terraform@a1502cd9e758c50496cc9ac5308c4843bcd56d36  # v3.0.0

Use Dependabot for Actions too:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "monthly"
    reviewers:
      - "devops-team"

Avoiding YAML Drift with Anchors

Use YAML anchors to keep workflows DRY:

name: Terraform Multi-Environment

on:
  push:
    branches: [main]

# Define reusable configuration
x-terraform-defaults: &terraform-defaults
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: hashicorp/setup-terraform@v3
      with:
        terraform_version: 1.7.0

jobs:
  dev:
    <<: *terraform-defaults
    environment: dev
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
        working-directory: environments/dev
      - run: terraform apply -auto-approve
        working-directory: environments/dev

  staging:
    <<: *terraform-defaults
    environment: staging
    needs: dev
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
        working-directory: environments/staging
      - run: terraform apply -auto-approve
        working-directory: environments/staging

Refactoring as Infrastructure Scales

When workflows become unwieldy:

Before (Monolithic):

# 500+ lines in one file
jobs:
  plan_dev:
    # ... 50 lines ...
  plan_staging:
    # ... 50 lines ...
  plan_prod:
    # ... 50 lines ...
  # ... etc

After (Modular):

# terraform-workflow.yml (10 lines)
jobs:
  dev:
    uses: ./.github/workflows/terraform-reusable.yml
    with:
      environment: dev
  
  staging:
    uses: ./.github/workflows/terraform-reusable.yml
    with:
      environment: staging


Comparisons & When to Use a Specialized Tool

GitHub Actions is powerful, but specialized Terraform tools exist for a reason. Understanding when to use what prevents over-engineering or under-tooling.

GitHub Actions vs Atlantis

GitHub Actions Strengths:

  • Native GitHub integration
  • No additional infrastructure to manage
  • Flexible workflow customization
  • Free for public repos, generous limits for private
  • Unified CI/CD platform (test + deploy apps and infrastructure)

Atlantis Strengths:

  • Purpose-built for Terraform
  • Better PR comment interface with apply buttons
  • Automatic plan on PR update
  • Atlantis config file for repo-specific rules
  • Better handling of multi-directory repos

Comparison Table:

FeatureGitHub ActionsAtlantis
Setup ComplexityLowMedium (requires hosting)
Terraform-Specific FeaturesBasicAdvanced
PR CommentsManual scriptingBuilt-in rich UI
Multi-Repo SupportNativeRequires configuration
CostIncluded with GitHubInfrastructure costs
Custom WorkflowsHighly flexibleLimited to Atlantis patterns
Learning CurveGitHub Actions knowledgeAtlantis-specific

GitHub Actions vs Terragrunt

Terragrunt isn’t a CI/CD platform—it’s a Terraform wrapper. Use them together:

- name: Setup Terragrunt
  run: |
    wget https://github.com/gruntwork-io/terragrunt/releases/download/v0.55.0/terragrunt_linux_amd64
    chmod +x terragrunt_linux_amd64
    sudo mv terragrunt_linux_amd64 /usr/local/bin/terragrunt

- name: Terragrunt Plan All
  run: terragrunt run-all plan
  working-directory: infrastructure/

Terragrunt solves DRY Terraform configurations. GitHub Actions solves CI/CD automation. They’re complementary.

GitHub Actions vs Spacelift/env0/Terraform Cloud

Managed Terraform Platforms Offer:

  • Built-in state management
  • Advanced policy enforcement (OPA, Sentinel)
  • Cost estimation before apply
  • Drift detection scheduling
  • Private module registry
  • SSO and advanced RBAC
  • Visual workflow DAGs

GitHub Actions Better When:

  • Budget-conscious (Actions is included)
  • Simple infrastructure requirements
  • Already invested in GitHub ecosystem
  • Want full control over workflow logic
  • Need to combine app and infra CI/CD

Use Cases Comparison:

ScenarioRecommendation
Startup with simple infrastructureGitHub Actions
Enterprise with compliance requirementsSpacelift/Terraform Cloud
Open source projectsGitHub Actions
Multi-cloud with complex policiesManaged platform
Moderate complexity, tight budgetGitHub Actions
Need advanced governance featuresManaged platform

Hybrid Approaches

Many teams use combinations:

Pattern 1: Actions for CI, Atlantis for CD

  • GitHub Actions: Run tests, validation, security scans
  • Atlantis: Handle Terraform plan/apply with approval workflow

Pattern 2: Actions with Terraform Cloud Backend

  • GitHub Actions: Trigger workflow
  • Terraform Cloud: Execute runs, store state, enforce policies
- name: Terraform Cloud Run
  run: |
    cat > run.json << EOF
    {
      "data": {
        "attributes": {
          "message": "Triggered by GitHub Actions"
        },
        "type": "runs",
        "relationships": {
          "workspace": {
            "data": {
              "type": "workspaces",
              "id": "${{ secrets.TFC_WORKSPACE_ID }}"
            }
          }
        }
      }
    }
    EOF
    
    curl \
      --header "Authorization: Bearer ${{ secrets.TFC_TOKEN }}" \
      --header "Content-Type: application/vnd.api+json" \
      --request POST \
      --data @run.json \
      https://app.terraform.io/api/v2/runs

When GitHub Actions is Sufficient

Stick with GitHub Actions if you:

  • Have fewer than 50 Terraform modules
  • Don’t need advanced policy enforcement
  • Operate within 3-5 environments
  • Have a small team (< 20 people)
  • Want to minimize tool sprawl
  • Need tight integration with application CI/CD

Real-World Case Studies & Lessons Learned

Learning from production deployments saves countless hours of troubleshooting.

Case Study 1: Startup Scaling from 5 to 50 Services

Challenge: A SaaS startup grew from a monolithic application to 50 microservices, each needing dedicated infrastructure. Their single Terraform repository became unmanageable.

Solution: Implemented matrix workflows with service-specific directories:

infrastructure/
├── services/
│   ├── auth-service/
│   ├── payment-service/
│   └── notification-service/
└── shared/
    ├── networking/
    └── databases/

Key Workflow Pattern:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      services: ${{ steps.filter.outputs.changes }}
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v2
        id: filter
        with:
          filters: |
            auth: 'infrastructure/services/auth-service/**'
            payment: 'infrastructure/services/payment-service/**'
            notification: 'infrastructure/services/notification-service/**'
  
  terraform:
    needs: detect-changes
    if: ${{ needs.detect-changes.outputs.services != '[]' }}
    strategy:
      matrix:
        service: ${{ fromJSON(needs.detect-changes.outputs.services) }}
    # ... apply matrix workflow

Result: Deployment time reduced from 45 minutes to 8 minutes. Only changed services get processed.

Case Study 2: Financial Institution with Strict Compliance

Challenge: A fintech company needed SOC 2 compliance, requiring audit trails, approvals, and policy enforcement for all infrastructure changes.

Solution: Multi-stage approval workflow with artifact retention:

jobs:
  plan:
    # Generate plan, upload encrypted artifact
  
  security-scan:
    needs: plan
    # Run tfsec, checkov on plan
  
  compliance-review:
    needs: security-scan
    environment: compliance-team  # 2 approvers required
    # Manual review
  
  ops-approval:
    needs: compliance-review
    environment: operations  # 1 approver required
    # Final approval
  
  apply:
    needs: ops-approval
    # Apply with full audit logging

Result: Passed SOC 2 audit with complete audit trail. All changes reviewed by security and operations.

Case Study 3: Common Mistakes and How to Avoid Them

Mistake 1: Leaking Secrets in Plan Output

A team accidentally exposed database passwords in Terraform plan outputs that were commented on public PRs.

The Problem: Raw terraform plan output includes sensitive values that should never be exposed in PR comments, even on private repositories. Values marked as sensitive = true in Terraform still appear in plan outputs.

The Solution: Use JSON plan output with sanitization that extracts only safe summary data:

- name: Generate Safe Plan Summary
  run: |
    terraform plan -out=tfplan.binary
    terraform show -json tfplan.binary > tfplan.json

- name: Comment Sanitized Plan on PR
  if: github.event_name == 'pull_request'
  uses: actions/github-script@v7
  with:
    script: |
      const fs = require('fs');
      const planJson = JSON.parse(fs.readFileSync('tfplan.json', 'utf8'));
      
      // Extract only safe summary data
      const resourceChanges = planJson.resource_changes || [];
      const toAdd = resourceChanges.filter(r => r.change.actions.includes('create')).length;
      const toChange = resourceChanges.filter(r => r.change.actions.includes('update')).length;
      const toDestroy = resourceChanges.filter(r => r.change.actions.includes('delete')).length;
      
      // Build resource type summary
      const resourceTypes = {};
      resourceChanges.forEach(r => {
        const type = r.type;
        if (!resourceTypes[type]) resourceTypes[type] = { add: 0, change: 0, destroy: 0 };
        if (r.change.actions.includes('create')) resourceTypes[type].add++;
        if (r.change.actions.includes('update')) resourceTypes[type].change++;
        if (r.change.actions.includes('delete')) resourceTypes[type].destroy++;
      });
      
      let typeTable = '| Resource Type | Add | Change | Destroy |\n|---------------|-----|--------|---------|';
      Object.keys(resourceTypes).sort().forEach(type => {
        const counts = resourceTypes[type];
        typeTable += `\n| ${type} | ${counts.add} | ${counts.change} | ${counts.destroy} |`;
      });
      
      // List affected resources (names only, no values)
      const affectedResources = resourceChanges.map(r => 
        `- **${r.address}** (${r.change.actions.join(', ')})`
      ).join('\n');
      
      const output = `#### Terraform Plan Summary 📊
      
**Plan:** ${toAdd} to add, ${toChange} to change, ${toDestroy} to destroy

<details>
<summary>Resource Type Summary</summary>

${typeTable}

</details>

<details>
<summary>Affected Resources</summary>

${affectedResources}

</details>

**Note:** Sensitive values are not displayed. Review full plan in workflow logs.

*Pushed by: @${{ github.actor }}*`;
      
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: output
      });

This approach provides actionable plan information without exposing sensitive data. The full plan with all details remains available in the workflow logs, which have proper access controls.

Mistake 2: Skipping Approvals for “Small” Changes

A developer bypassed the approval gate for a “quick DNS change,” accidentally deleting the production load balancer configuration.

Fix: Enforce approvals programmatically:

- name: Require Approval for Any Production Change
  if: github.ref == 'refs/heads/main'
  run: |
    # This job MUST be in an environment with required reviewers
    echo "Production changes require approval"
    
# The job MUST specify an environment
jobs:
  apply:
    environment: production  # Non-negotiable for main branch

Lesson: Never allow production applies without approval, regardless of perceived change size. Use GitHub branch protection rules to enforce this at the repository level.

Mistake 3: Applying Stale Plans

A plan generated on Monday was applied on Friday, but the main branch had advanced by 30 commits. The apply conflicted with recent changes, corrupting state.

Fix:

- name: Validate Plan Freshness Against Current State
  run: |
    # Generate a new plan from current state
    terraform plan -out=current-plan
    
    # Compare plan hash with original
    CURRENT_HASH=$(terraform show -json current-plan | sha256sum)
    ORIGINAL_HASH=$(cat plan-hash.txt)
    
    if [ "$CURRENT_HASH" != "$ORIGINAL_HASH" ]; then
      echo "ERROR: Plan is stale. State has changed since plan generation."
      echo "Regenerate plan before applying."
      exit 1
    fi

Lesson: Always validate that the plan matches current state before applying. Maximum plan age should be 4-6 hours in active environments.

Lessons from Large Teams Running Terraform on Actions

Insight 1: Monorepo vs Multi-Repo

Teams with 100+ microservices find that monorepos with path filtering work better than multi-repo setups. Shared modules and consistent workflow patterns reduce maintenance burden.

Insight 2: Plan Storage Strategy

Teams running 50+ plans per day find that plan artifacts with 5-day retention balance storage costs with the need for historical analysis. Compressed artifacts (gzip tfplan) reduce storage by 70%.

Insight 3: Concurrency Limits

GitHub Actions allows 20 concurrent jobs on free plans, 180 on Team plans. Large teams hit these limits. The solution:

concurrency:
  group: terraform-${{ github.repository }}
  cancel-in-progress: false

# Plus queue-based processing for applies

Insight 4: Self-Hosted Runners for Speed

Teams running Terraform against large state files (10,000+ resources) see 3-5x faster execution on self-hosted runners with local provider caching. Read more in our GitHub Actions Self-Hosted Runner Guide.


Conclusion & Call to Action

Terraform GitHub Actions creates a powerful IaC automation platform that’s accessible, scalable, and production-ready. By following the patterns in this guide—secure credential handling with OIDC, plan artifact strategies, approval gates, concurrency control, and comprehensive observability—you’ll build pipelines that are both safe and efficient.

Best Practices Recap

Security:

  • Always use OIDC over static credentials
  • Encrypt plan artifacts
  • Sanitize outputs before commenting on PRs
  • Implement least-privilege IAM policies

Reliability:

  • Separate plan and apply jobs with artifact passing
  • Validate plan freshness before applying
  • Implement retry logic with exponential backoff
  • Use remote state locking (DynamoDB for S3, GCS locking, etc.)

Governance:

  • Require approvals for production environments
  • Maintain comprehensive audit trails
  • Use GitHub Environments with branch restrictions
  • Implement policy-as-code scanning (tfsec, checkov)

Scalability:

  • Use reusable workflows for DRY principles
  • Implement matrix strategies for multi-module repos
  • Use concurrency groups to prevent conflicting applies
  • Cache provider plugins on self-hosted runners

Critical Warnings

⚠️ Never apply plans older than 6 hours in production
⚠️ Always encrypt plan artifacts if they contain sensitive data
⚠️ Don’t skip approvals for “quick fixes”—this is when disasters happen
⚠️ Test Terraform upgrades in non-production environments first
⚠️ Monitor for drift regularly—manual changes bypass Terraform tracking

Suggested Next Steps

  1. Audit Your Current Workflows: Review existing pipelines against this guide. Identify security gaps and missing approval gates.
  2. Implement OIDC: Migrate from static credentials to OIDC-based authentication. This single change dramatically improves security posture.
  3. Add Drift Detection: Schedule daily or weekly drift checks. Automated detection prevents configuration drift from accumulating.
  4. Enhance Observability: Add structured logging, audit trails, and metrics collection. You can’t improve what you don’t measure.
  5. Establish Approval Processes: If you haven’t already, require approvals for production changes. Use GitHub Environments with required reviewers.
  6. Document Runbooks: Create incident response procedures for common failure scenarios: state corruption, failed applies, credential rotation.

Join the Conversation

Have you implemented Terraform with GitHub Actions in production? Share your experiences, challenges, and solutions in the comments below. The DevOps community thrives on shared knowledge.

Questions to consider:

  • What’s your biggest challenge with Terraform CI/CD?
  • Have you encountered issues not covered in this guide?
  • How do you handle multi-cloud Terraform deployments?
  • What’s your strategy for managing Terraform state at scale?

For more DevOps automation content, explore our related guides:


Appendix: Terraform GitHub Actions Cheat Sheet

Quick Reference: Building a Terraform Workflow Step by Step

Follow these steps to create a production-ready Terraform GitHub Actions workflow:

  1. Checkout Repository: Use actions/checkout@v4 to clone your Terraform code
  2. Setup Terraform: Use hashicorp/setup-terraform@v3 with pinned version (e.g., 1.7.0)
  3. Initialize Backend: Run terraform init with remote backend configuration (S3, GCS, Azure)
  4. Generate Plan: Run terraform plan -out=tfplan and save as artifact with encryption
  5. Require Review: Configure GitHub Environment with required approvers for production
  6. Apply Plan: Execute terraform apply tfplan using the exact artifact from step 4
  7. Store Logs: Upload Terraform logs and state backups as artifacts with 90-day retention
  8. Monitor: Implement scheduled drift detection and metrics collection for continuous improvement

Common YAML Building Blocks

Basic Terraform Job Template:

jobs:
  terraform:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
      id-token: write
      pull-requests: write
    
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: 1.7.0
      - run: terraform init
      - run: terraform plan
      - run: terraform apply -auto-approve

OIDC Authentication Block:

- name: Configure AWS Credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME
    aws-region: us-east-1

Plan Artifact Upload:

- name: Upload Plan
  uses: actions/upload-artifact@v4
  with:
    name: terraform-plan-${{ github.sha }}
    path: tfplan.binary
    retention-days: 5

PR Comment with Plan Output:

- name: Comment Plan
  uses: actions/github-script@v7
  with:
    script: |
      const fs = require('fs');
      const planJson = JSON.parse(fs.readFileSync('tfplan.json', 'utf8'));
      
      // Extract safe summary
      const resourceChanges = planJson.resource_changes || [];
      const toAdd = resourceChanges.filter(r => r.change.actions.includes('create')).length;
      const toChange = resourceChanges.filter(r => r.change.actions.includes('update')).length;
      const toDestroy = resourceChanges.filter(r => r.change.actions.includes('delete')).length;
      
      const output = `#### Terraform Plan Summary\n**Plan:** ${toAdd} to add, ${toChange} to change, ${toDestroy} to destroy\n\n*Review full plan in workflow logs*`;
      
      github.rest.issues.createComment({
        issue_number: context.issue.number,
        owner: context.repo.owner,
        repo: context.repo.repo,
        body: output
      });

Concurrency Control:

concurrency:
  group: terraform-${{ github.workflow }}-${{ inputs.environment }}
  cancel-in-progress: false

Matrix for Multiple Environments:

strategy:
  matrix:
    environment: [dev, staging, production]
    include:
      - environment: dev
        aws_role: arn:aws:iam::111111111111:role/DevRole
      - environment: staging
        aws_role: arn:aws:iam::222222222222:role/StagingRole
      - environment: production
        aws_role: arn:aws:iam::333333333333:role/ProdRole

Reusable Workflow Template

Save this as .github/workflows/terraform-reusable.yml:

name: Reusable Terraform Workflow

on:
  workflow_call:
    inputs:
      environment:
        required: true
        type: string
      working_directory:
        required: true
        type: string
      terraform_version:
        required: false
        type: string
        default: '1.7.0'
      auto_apply:
        required: false
        type: boolean
        default: false
    secrets:
      aws_role_arn:
        required: true
      slack_webhook:
        required: false

permissions:
  contents: read
  id-token: write
  pull-requests: write

jobs:
  terraform:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    
    defaults:
      run:
        working-directory: ${{ inputs.working_directory }}
    
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      
      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ inputs.terraform_version }}
      
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.aws_role_arn }}
          aws-region: us-east-1
      
      - name: Terraform Init
        run: terraform init
      
      - name: Terraform Validate
        run: terraform validate
      
      - name: Terraform Plan
        id: plan
        run: terraform plan -out=tfplan -no-color
        continue-on-error: true
      
      - name: Upload Plan Artifact
        uses: actions/upload-artifact@v4
        with:
          name: tfplan-${{ inputs.environment }}-${{ github.sha }}
          path: ${{ inputs.working_directory }}/tfplan
          retention-days: 5
      
      - name: Terraform Apply
        if: inputs.auto_apply &amp;&amp; github.ref == 'refs/heads/main'
        run: terraform apply -auto-approve tfplan
      
      - name: Notify Slack
        if: always() &amp;&amp; secrets.slack_webhook != ''
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Terraform ${{ job.status }} - ${{ inputs.environment }}",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*Environment:* ${{ inputs.environment }}\n*Status:* ${{ job.status }}\n*Triggered by:* ${{ github.actor }}"
                  }
                }
              ]
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.slack_webhook }}

Token and Permission Matrix

PermissionPurposeRisk LevelRecommended Scope
contents: readRead repository codeLowAll workflows
contents: writePush commits/tagsHighOnly for release workflows
pull-requests: writeComment on PRsLowPlan workflows
id-token: writeOIDC token generationMediumWorkflows needing cloud access
issues: writeCreate issuesLowDrift detection workflows
deployments: writeCreate deploymentsMediumApply workflows
actions: readRead workflow artifactsLowApply workflows (for plan artifacts)
ActionPurposeMarketplace Link
hashicorp/setup-terraformInstall Terraform CLIMarketplace
dflook/terraform-planAdvanced plan with PR commentsMarketplace
dflook/terraform-applyApply with artifact managementMarketplace
aquasecurity/tfsec-actionSecurity scanning for TerraformMarketplace
bridgecrewio/checkov-actionPolicy-as-code scanningMarketplace
terraform-linters/tflintLinting for Terraform codeMarketplace
aws-actions/configure-aws-credentialsAWS OIDC authenticationMarketplace

ASCII Workflow Diagram

┌─────────────────────────────────────────────────────────────────┐
│                    Terraform GitHub Actions Workflow             │
└─────────────────────────────────────────────────────────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │   Git Push/PR Event     │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │  Checkout Repository    │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │   Setup Terraform CLI   │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │    Authenticate via     │
                    │     OIDC/Secrets        │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │    Terraform Init       │
                    │  (Remote Backend Lock)  │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │    Terraform Plan       │
                    │   (Generate Changes)    │
                    └────────────┬────────────┘
                                 │
                    ┌────────────▼────────────┐
                    │   Upload Plan Artifact  │
                    │     (Encrypted)         │
                    └────────────┬────────────┘
                                 │
                ┌────────────────┴────────────────┐
                │                                  │
    ┌───────────▼──────────┐          ┌──────────▼──────────┐
    │   PR: Comment Plan   │          │  Main: Wait for     │
    │   Exit (No Apply)    │          │  Approval Gate      │
    └──────────────────────┘          └──────────┬──────────┘
                                                  │
                                      ┌───────────▼──────────┐
                                      │  Download Plan       │
                                      │  Artifact            │
                                      └───────────┬──────────┘
                                                  │
                                      ┌───────────▼──────────┐
                                      │  Terraform Apply     │
                                      │  (Execute Changes)   │
                                      └───────────┬──────────┘
                                                  │
                                      ┌───────────▼──────────┐
                                      │  Store Logs/Audit    │
                                      │  Notify Team         │
                                      └──────────────────────┘


Comparison Tables

GitHub Actions vs Atlantis vs Terragrunt

FeatureGitHub ActionsAtlantisTerragrunt
TypeCI/CD PlatformTerraform Automation ServerTerraform Wrapper
HostingGitHub-managedSelf-hostedN/A (CLI tool)
Setup Time< 30 minutes1-2 hours< 15 minutes
CostFree tier + $0.008/minuteInfrastructure costsFree
PR IntegrationVia scriptingNative rich UIVia CI/CD platform
Plan on PRCustom workflowAutomaticVia CI/CD trigger
Apply ControlEnvironment approvalsPR comments + locksManual or CI/CD
Multi-DirectoryMatrix strategyBuilt-in supportNative run-all
State ManagementRemote backendsRemote backendsRemote backends + config DRY
Learning CurveModerateLow-MediumLow
Best ForUnified CI/CD platformDedicated Terraform automationDRY configurations
Community SizeVery LargeMediumLarge

OIDC vs Static Credentials

AspectOIDC (Recommended)Static Credentials (Legacy)
SecurityShort-lived tokens (1 hour)Long-lived keys (no expiry)
RotationAutomaticManual every 90 days
Compromised RiskLow (tokens expire quickly)High (keys valid indefinitely)
Setup ComplexityMedium (IAM trust policy)Low (store in Secrets)
Audit TrailPer-workflow session logsShared credential logs
MaintenanceMinimal (token refresh automatic)High (rotation, monitoring)
CostFreeFree
ComplianceMeets most security standardsMay fail security audits
RevocationInstant (update trust policy)Manual (delete key, create new)

Fully Automated vs Gated Apply

FactorFully AutomatedManual Approval Gate
SpeedInstant (seconds)Minutes to hours
SafetyDepends on testsHuman validation layer
ScalabilityUnlimitedLimited by reviewer availability
ComplianceRequires extensive testingEasier to satisfy auditors
Best ForDev environmentsProduction environments
Required SkillsAdvanced testing strategyStandard review process
Bottleneck RiskNoneHigh during off-hours
Error PreventionAutomated checks onlyAutomated + human checks

Frequently Asked Questions

How do I use Terraform with GitHub Actions?

To use Terraform with GitHub Actions, create a workflow file (.github/workflows/terraform.yml) that includes steps to checkout your code, install Terraform using hashicorp/setup-terraform, authenticate to your cloud provider, and run Terraform commands (init, plan, apply). Use GitHub Secrets for credentials and GitHub Environments for approval gates on production applies.

What is the best GitHub Action for Terraform?

The hashicorp/setup-terraform action is the official and most widely used option, providing Terraform CLI installation and output capturing. For advanced features like automatic PR commenting and plan artifact management, consider dflook/terraform-plan and dflook/terraform-apply actions, which offer purpose-built Terraform workflow enhancements.

How do I secure Terraform secrets in GitHub Actions?

Secure Terraform secrets using GitHub’s encrypted Secrets for sensitive values, and implement OIDC (OpenID Connect) for cloud provider authentication instead of static credentials. OIDC provides short-lived tokens that automatically expire, eliminating the risk of leaked long-lived access keys. Always encrypt plan artifacts before uploading and sanitize plan outputs before commenting on pull requests.

How can I prevent stale Terraform plans from being applied?

Prevent stale plans by implementing plan freshness validation that compares the plan’s generation timestamp against a maximum age threshold (typically 1-6 hours). Generate plans in the same job or workflow run as the apply operation, passing plans via encrypted artifacts. Use GitHub Actions concurrency controls to prevent race conditions where multiple plans might be generated simultaneously.

Can I run Terraform in a monorepo using GitHub Actions?

Yes, GitHub Actions supports monorepo Terraform workflows using matrix strategies and path-based filtering. Configure workflows to trigger only when specific directories change using paths filters, and use matrix builds to run Terraform across multiple modules simultaneously. Reusable workflows help maintain DRY principles across different infrastructure components in your monorepo.

Is GitHub Actions enough for Terraform, or should I use Atlantis or Terragrunt?

GitHub Actions is sufficient for most teams with moderate infrastructure complexity (under 50 modules, fewer than 20 engineers). Choose specialized tools like Atlantis if you need purpose-built Terraform PR workflows with rich commenting UI, or Spacelift/Terraform Cloud if you require advanced policy enforcement and governance features. Terragrunt complements any CI/CD platform by eliminating configuration duplication, and can be used alongside GitHub Actions.

How do I handle Terraform state locking in GitHub Actions?

Terraform state locking is handled automatically by remote backends. For AWS S3 backends, configure a DynamoDB table in your backend configuration. For Google Cloud Storage, locking is built-in. For Azure Blob Storage, use lease-based locking. GitHub Actions concurrency groups provide an additional layer of workflow-level locking to prevent multiple workflow runs from conflicting.

What’s the best way to manage multiple environments with Terraform in GitHub Actions?

Use reusable workflows with environment-specific inputs, GitHub Environments for approval gates, and separate state files per environment. Structure your repository with environment directories (environments/dev, environments/staging, environments/production) and use matrix strategies or separate workflow files to manage each environment’s deployment pipeline independently.


Downloadable Resources

Terraform GitHub Actions Security Checklist (PDF)

Pre-Deployment Security:

  • [ ] OIDC authentication configured (no static credentials)
  • [ ] GitHub Secrets encrypted and access-controlled
  • [ ] IAM roles follow least-privilege principle
  • [ ] Plan artifacts encrypted before upload
  • [ ] Sensitive outputs redacted from PR comments
  • [ ] Branch protection rules enforced on main branch
  • [ ] Required status checks configured

Runtime Security:

  • [ ] Remote state backend with encryption at rest
  • [ ] State locking mechanism configured (DynamoDB/GCS)
  • [ ] Terraform version pinned in workflows
  • [ ] Provider versions pinned in configuration
  • [ ] Security scanning integrated (tfsec/checkov)
  • [ ] Plan freshness validation implemented
  • [ ] Audit logging enabled for all applies

Post-Deployment Security:

  • [ ] Workflow artifacts retained for compliance period
  • [ ] Drift detection scheduled and monitored
  • [ ] Incident response runbook documented
  • [ ] Access logs reviewed monthly
  • [ ] Credentials rotated per policy (if not using OIDC)
  • [ ] Workflow files version-controlled and reviewed

Compliance:

  • [ ] SOC 2 requirements met (if applicable)
  • [ ] PCI-DSS requirements met (if applicable)
  • [ ] HIPAA requirements met (if applicable)
  • [ ] GDPR data handling verified (if applicable)

Visual Workflow Diagram

┌─────────────────────────────────────────────────────────────────────────┐
│          TERRAFORM GITHUB ACTIONS COMPLETE LIFECYCLE                     │
└─────────────────────────────────────────────────────────────────────────┘

   Developer              GitHub                 Terraform              Cloud
   Workflow              Actions                 Engine               Provider
      │                    │                        │                    │
      │                    │                        │                    │
  ┌───┴───┐                │                        │                    │
  │ Write │                │                        │                    │
  │ .tf   │                │                        │                    │
  │ Code  │                │                        │                    │
  └───┬───┘                │                        │                    │
      │                    │                        │                    │
      │ git push           │                        │                    │
      ├───────────────────>│                        │                    │
      │                    │                        │                    │
      │              ┌─────┴─────┐                  │                    │
      │              │  Trigger  │                  │                    │
      │              │  Workflow │                  │                    │
      │              └─────┬─────┘                  │                    │
      │                    │                        │                    │
      │                    │  checkout              │                    │
      │                    │  setup terraform       │                    │
      │                    ├───────────────────────>│                    │
      │                    │                        │                    │
      │                    │                   ┌────┴────┐               │
      │                    │                   │terraform│               │
      │                    │                   │  init   │               │
      │                    │                   └────┬────┘               │
      │                    │                        │ authenticate       │
      │                    │                        ├───────────────────>│
      │                    │                        │                    │
      │                    │                   ┌────┴────┐          ┌────┴────┐
      │                    │                   │terraform│          │  Fetch  │
      │                    │                   │  plan   │          │  State  │
      │                    │                   └────┬────┘          └────┬────┘
      │                    │                        │&lt;──────────────────>│
      │                    │                        │                    │
      │                    │&lt;───plan artifact───────┤                    │
      │                    │                        │                    │
      │              ┌─────┴─────┐                  │                    │
      │              │ Comment   │                  │                    │
      │&lt;─────────────┤ Plan on   │                  │                    │
      │   review     │    PR     │                  │                    │
      │              └─────┬─────┘                  │                    │
      │                    │                        │                    │
      │  approve merge     │                        │                    │
      ├───────────────────>│                        │                    │
      │                    │                        │                    │
      │              ┌─────┴─────┐                  │                    │
      │              │  Require  │                  │                    │
      │              │ Approval  │                  │                    │
      │              │   Gate    │                  │                    │
      │              └─────┬─────┘                  │                    │
      │                    │                        │                    │
      │   ✓ approved       │  download artifact     │                    │
      │                    ├───────────────────────>│                    │
      │                    │                        │                    │
      │                    │                   ┌────┴────┐               │
      │                    │                   │terraform│               │
      │                    │                   │  apply  │               │
      │                    │                   └────┬────┘               │
      │                    │                        │ create/modify      │
      │                    │                        ├───────────────────>│
      │                    │                        │                    │
      │                    │                        │              ┌─────┴─────┐
      │                    │                        │              │ Provision │
      │                    │                        │              │ Resources │
      │                    │                        │              └─────┬─────┘
      │                    │                        │&lt;──────────────────>│
      │                    │                        │                    │
      │                    │&lt;────logs/audit────────>│                    │
      │                    │                        │                    │
      │              ┌─────┴─────┐                  │                    │
      │&lt;─────────────┤  Notify   │                  │                    │
      │   success    │   Team    │                  │                    │
      │              └───────────┘                  │                    │
      │                                             │                    │
      
    SCHEDULED DRIFT DETECTION (runs daily/weekly)
      
      │                    │                        │                    │
      │              ┌─────┴─────┐                  │                    │
      │              │ Scheduled │                  │                    │
      │              │   Drift   │                  │                    │
      │              │   Check   │                  │                    │
      │              └─────┬─────┘                  │                    │
      │                    ├───────────────────────>│                    │
      │                    │                        │                    │
      │                    │                   ┌────┴────┐               │
      │                    │                   │terraform│               │
      │                    │                   │  plan   │               │
      │                    │                   └────┬────┘               │
      │                    │                        │ compare state      │
      │                    │                        ├───────────────────>│
      │                    │                        │                    │
      │              ┌─────┴─────┐                  │                    │
      │&lt;─────────────┤  Create   │                  │                    │
      │   if drift   │  Issue    │                  │                    │
      │   detected   └───────────┘                  │                    │
      │                                             │                    │

Similar Posts

Leave a Reply