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
Table of Contents
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:
- Provisioning new infrastructure: Deploying VPCs, Kubernetes clusters, databases, and networking resources automatically when code merges
- Validating pull requests: Running
terraform planon every PR to catch errors before they reach main branch - Compliance checking: Integrating policy-as-code tools like Open Policy Agent or Sentinel to enforce security standards
- Drift detection: Scheduled workflows that run plans to identify manual changes made outside Terraform
- 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:
- Checkout: Clone the repository code
- Setup Terraform: Install the Terraform CLI binary
- Init: Initialize the backend and download providers
- Plan: Generate an execution plan showing proposed changes
- 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 }}"
OIDC Authentication (Recommended)
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.
Recommended Pattern: Plan Artifact Strategy
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 << 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:
| Feature | GitHub Actions | Atlantis |
|---|---|---|
| Setup Complexity | Low | Medium (requires hosting) |
| Terraform-Specific Features | Basic | Advanced |
| PR Comments | Manual scripting | Built-in rich UI |
| Multi-Repo Support | Native | Requires configuration |
| Cost | Included with GitHub | Infrastructure costs |
| Custom Workflows | Highly flexible | Limited to Atlantis patterns |
| Learning Curve | GitHub Actions knowledge | Atlantis-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:
| Scenario | Recommendation |
|---|---|
| Startup with simple infrastructure | GitHub Actions |
| Enterprise with compliance requirements | Spacelift/Terraform Cloud |
| Open source projects | GitHub Actions |
| Multi-cloud with complex policies | Managed platform |
| Moderate complexity, tight budget | GitHub Actions |
| Need advanced governance features | Managed 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
- Audit Your Current Workflows: Review existing pipelines against this guide. Identify security gaps and missing approval gates.
- Implement OIDC: Migrate from static credentials to OIDC-based authentication. This single change dramatically improves security posture.
- Add Drift Detection: Schedule daily or weekly drift checks. Automated detection prevents configuration drift from accumulating.
- Enhance Observability: Add structured logging, audit trails, and metrics collection. You can’t improve what you don’t measure.
- Establish Approval Processes: If you haven’t already, require approvals for production changes. Use GitHub Environments with required reviewers.
- 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:
- GitHub Actions Self-Hosted Runner Guide
- GitHub Hosted Runner Explained
- AWS VPC Setup with Terraform
- Kubernetes Deployments with GitHub Actions
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:
- Checkout Repository: Use
actions/checkout@v4to clone your Terraform code - Setup Terraform: Use
hashicorp/setup-terraform@v3with pinned version (e.g., 1.7.0) - Initialize Backend: Run
terraform initwith remote backend configuration (S3, GCS, Azure) - Generate Plan: Run
terraform plan -out=tfplanand save as artifact with encryption - Require Review: Configure GitHub Environment with required approvers for production
- Apply Plan: Execute
terraform apply tfplanusing the exact artifact from step 4 - Store Logs: Upload Terraform logs and state backups as artifacts with 90-day retention
- 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 && github.ref == 'refs/heads/main'
run: terraform apply -auto-approve tfplan
- name: Notify Slack
if: always() && 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
| Permission | Purpose | Risk Level | Recommended Scope |
|---|---|---|---|
contents: read | Read repository code | Low | All workflows |
contents: write | Push commits/tags | High | Only for release workflows |
pull-requests: write | Comment on PRs | Low | Plan workflows |
id-token: write | OIDC token generation | Medium | Workflows needing cloud access |
issues: write | Create issues | Low | Drift detection workflows |
deployments: write | Create deployments | Medium | Apply workflows |
actions: read | Read workflow artifacts | Low | Apply workflows (for plan artifacts) |
Recommended Terraform-Related GitHub Actions
| Action | Purpose | Marketplace Link |
|---|---|---|
hashicorp/setup-terraform | Install Terraform CLI | Marketplace |
dflook/terraform-plan | Advanced plan with PR comments | Marketplace |
dflook/terraform-apply | Apply with artifact management | Marketplace |
aquasecurity/tfsec-action | Security scanning for Terraform | Marketplace |
bridgecrewio/checkov-action | Policy-as-code scanning | Marketplace |
terraform-linters/tflint | Linting for Terraform code | Marketplace |
aws-actions/configure-aws-credentials | AWS OIDC authentication | Marketplace |
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
| Feature | GitHub Actions | Atlantis | Terragrunt |
|---|---|---|---|
| Type | CI/CD Platform | Terraform Automation Server | Terraform Wrapper |
| Hosting | GitHub-managed | Self-hosted | N/A (CLI tool) |
| Setup Time | < 30 minutes | 1-2 hours | < 15 minutes |
| Cost | Free tier + $0.008/minute | Infrastructure costs | Free |
| PR Integration | Via scripting | Native rich UI | Via CI/CD platform |
| Plan on PR | Custom workflow | Automatic | Via CI/CD trigger |
| Apply Control | Environment approvals | PR comments + locks | Manual or CI/CD |
| Multi-Directory | Matrix strategy | Built-in support | Native run-all |
| State Management | Remote backends | Remote backends | Remote backends + config DRY |
| Learning Curve | Moderate | Low-Medium | Low |
| Best For | Unified CI/CD platform | Dedicated Terraform automation | DRY configurations |
| Community Size | Very Large | Medium | Large |
OIDC vs Static Credentials
| Aspect | OIDC (Recommended) | Static Credentials (Legacy) |
|---|---|---|
| Security | Short-lived tokens (1 hour) | Long-lived keys (no expiry) |
| Rotation | Automatic | Manual every 90 days |
| Compromised Risk | Low (tokens expire quickly) | High (keys valid indefinitely) |
| Setup Complexity | Medium (IAM trust policy) | Low (store in Secrets) |
| Audit Trail | Per-workflow session logs | Shared credential logs |
| Maintenance | Minimal (token refresh automatic) | High (rotation, monitoring) |
| Cost | Free | Free |
| Compliance | Meets most security standards | May fail security audits |
| Revocation | Instant (update trust policy) | Manual (delete key, create new) |
Fully Automated vs Gated Apply
| Factor | Fully Automated | Manual Approval Gate |
|---|---|---|
| Speed | Instant (seconds) | Minutes to hours |
| Safety | Depends on tests | Human validation layer |
| Scalability | Unlimited | Limited by reviewer availability |
| Compliance | Requires extensive testing | Easier to satisfy auditors |
| Best For | Dev environments | Production environments |
| Required Skills | Advanced testing strategy | Standard review process |
| Bottleneck Risk | None | High during off-hours |
| Error Prevention | Automated checks only | Automated + 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 │
│ │ └────┬────┘ └────┬────┘
│ │ │<──────────────────>│
│ │ │ │
│ │<───plan artifact───────┤ │
│ │ │ │
│ ┌─────┴─────┐ │ │
│ │ Comment │ │ │
│<─────────────┤ Plan on │ │ │
│ review │ PR │ │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ approve merge │ │ │
├───────────────────>│ │ │
│ │ │ │
│ ┌─────┴─────┐ │ │
│ │ Require │ │ │
│ │ Approval │ │ │
│ │ Gate │ │ │
│ └─────┬─────┘ │ │
│ │ │ │
│ ✓ approved │ download artifact │ │
│ ├───────────────────────>│ │
│ │ │ │
│ │ ┌────┴────┐ │
│ │ │terraform│ │
│ │ │ apply │ │
│ │ └────┬────┘ │
│ │ │ create/modify │
│ │ ├───────────────────>│
│ │ │ │
│ │ │ ┌─────┴─────┐
│ │ │ │ Provision │
│ │ │ │ Resources │
│ │ │ └─────┬─────┘
│ │ │<──────────────────>│
│ │ │ │
│ │<────logs/audit────────>│ │
│ │ │ │
│ ┌─────┴─────┐ │ │
│<─────────────┤ Notify │ │ │
│ success │ Team │ │ │
│ └───────────┘ │ │
│ │ │
SCHEDULED DRIFT DETECTION (runs daily/weekly)
│ │ │ │
│ ┌─────┴─────┐ │ │
│ │ Scheduled │ │ │
│ │ Drift │ │ │
│ │ Check │ │ │
│ └─────┬─────┘ │ │
│ ├───────────────────────>│ │
│ │ │ │
│ │ ┌────┴────┐ │
│ │ │terraform│ │
│ │ │ plan │ │
│ │ └────┬────┘ │
│ │ │ compare state │
│ │ ├───────────────────>│
│ │ │ │
│ ┌─────┴─────┐ │ │
│<─────────────┤ Create │ │ │
│ if drift │ Issue │ │ │
│ detected └───────────┘ │ │
│ │ │
