GitHub Actions for Terraform
What is GitHub Actions for Terraform?
GitHub Actions for Terraform enables you to automate your Infrastructure as Code (IaC) workflows directly in your GitHub repository. You can build, test, plan, and apply Terraform configurations as part of your CI/CD pipeline without manual intervention.
Key Capabilities:
- Continuous Integration: Validate Terraform code on every commit
- Pull Request Automation: Auto-generate Terraform plans and attach to PRs
- Continuous Deployment: Automatically apply infrastructure changes after approval
- Drift Detection: Periodically scan for configuration drift
- Security Scanning: Detect security issues in infrastructure code (checkov, tfsec)
Why Use GitHub Actions for Terraform?
| Benefit | Description |
|---|---|
| Version Control | Infrastructure code alongside application code |
| Automated Testing | Validate syntax, formatting, and security on every commit |
| Visibility | Plans attached to PRs for easy review |
| Audit Trail | All changes tracked in Git history |
| Approvals | Manual gates before production deployments |
| Consistency | Same deployment process across all environments |
Architecture Pattern
┌─────────────────────────────────────────────────────────┐
│ GitHub Repository │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Terraform │ │ .github/ │ │
│ │ Code (.tf) │ │ workflows/ │ │
│ └──────────────┘ └──────────────┘ │
└───────────────────────┬─────────────────────────────────┘
│
┌───────────────┼───────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Terraform │ │ Terraform │ │ Drift │
│ Validate │ │ Plan/Apply │ │ Detection │
│ (On Commit) │ │ (On PR/Push)│ │ (Scheduled) │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
└────────────────┼────────────────┘
▼
┌──────────────┐
│ Azure │
│ Subscription │
└──────────────┘
Prerequisites
1. Terraform State Backend
Terraform state must be stored remotely for CI/CD workflows:
# backend.tf
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "sttfstateproduction"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
Create state storage:
# Create resource group
az group create \
--name rg-terraform-state \
--location eastus
# Create storage account
az storage account create \
--resource-group rg-terraform-state \
--name sttfstateproduction \
--sku Standard_LRS \
--encryption-services blob
# Get storage account key
ACCOUNT_KEY=$(az storage account keys list \
--resource-group rg-terraform-state \
--account-name sttfstateproduction \
--query '[0].value' -o tsv)
# Create blob container
az storage container create \
--name tfstate \
--account-name sttfstateproduction \
--account-key $ACCOUNT_KEY
2. Azure Authentication with OIDC (Recommended)
OpenID Connect (OIDC) allows GitHub Actions to authenticate to Azure without storing long-lived secrets.
Create Service Principal
# Create service principal
APP_NAME="github-actions-terraform"
az ad sp create-for-rbac \
--name $APP_NAME \
--role Contributor \
--scopes /subscriptions/{subscription-id}
# Note the output: appId, password, tenant
Configure Federated Credentials
# Get application object ID
APP_ID=$(az ad app list --display-name $APP_NAME --query '[0].appId' -o tsv)
# Create federated credential for main branch
az ad app federated-credential create \
--id $APP_ID \
--parameters '{
"name": "github-actions-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:YOUR_GITHUB_ORG/YOUR_REPO:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# Create federated credential for pull requests
az ad app federated-credential create \
--id $APP_ID \
--parameters '{
"name": "github-actions-pr",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:YOUR_GITHUB_ORG/YOUR_REPO:pull_request",
"audiences": ["api://AzureADTokenExchange"]
}'
Configure GitHub Secrets
In GitHub: Settings > Secrets and variables > Actions > New repository secret
AZURE_CLIENT_ID = <appId from service principal>
AZURE_TENANT_ID = <tenant from service principal>
AZURE_SUBSCRIPTION_ID = <your subscription id>
3. Create GitHub Environment
For production deployments with approvals:
- GitHub Repository > Settings > Environments > New environment
- Name:
production - Protection rules:
- ✅ Required reviewers (add approvers)
- ✅ Wait timer: 0 minutes
- ✅ Deployment branches:
mainonly
Terraform Validation Workflow
Runs on every commit to validate code quality:
# .github/workflows/terraform-validate.yml
name: Terraform Validation
on:
push:
branches:
- '**'
paths:
- 'terraform/**'
- '.github/workflows/terraform-validate.yml'
pull_request:
paths:
- 'terraform/**'
jobs:
terraform-validate:
name: Terraform Validation
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./terraform
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Terraform
uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.0
- name: Terraform Format Check
id: fmt
run: terraform fmt -check -recursive
continue-on-error: true
- name: Terraform Init
id: init
run: terraform init -backend=false
- name: Terraform Validate
id: validate
run: terraform validate -no-color
- name: Run Checkov (Security Scan)
id: checkov
uses: bridgecrewio/checkov-action@v12
with:
directory: terraform/
framework: terraform
soft_fail: true
output_format: cli,sarif
output_file_path: console,results.sarif
- name: Upload Checkov Results
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: results.sarif
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const output = `#### Terraform Format and Style 🖌\`${{ steps.fmt.outcome }}\`
#### Terraform Initialization ⚙️\`${{ steps.init.outcome }}\`
#### Terraform Validation 🤖\`${{ steps.validate.outcome }}\`
<details><summary>Validation Output</summary>
\`\`\`\n
${{ steps.validate.outputs.stdout }}
\`\`\`
</details>
*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
})
Terraform Plan/Apply Workflow
Runs on pull requests (plan) and main branch pushes (apply):
# .github/workflows/terraform-deploy.yml
name: Terraform Deploy
on:
pull_request:
branches:
- main
paths:
- 'terraform/**'
push:
branches:
- main
paths:
- 'terraform/**'
permissions:
id-token: write
contents: read
pull-requests: write
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_USE_OIDC: true
jobs:
terraform-plan:
name: Terraform Plan
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./terraform
outputs:
tfplanExitCode: ${{ steps.plan.outputs.exitcode }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login (OIDC)
uses: azure/login@v2
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
with:
terraform_version: 1.9.0
- name: Terraform Init
run: terraform init
- name: Terraform Plan
id: plan
run: |
terraform plan -detailed-exitcode -no-color -out=tfplan || export exitcode=$?
echo "exitcode=$exitcode" >> $GITHUB_OUTPUT
if [ $exitcode -eq 1 ]; then
echo "Terraform Plan Failed!"
exit 1
else
exit 0
fi
- name: Publish Terraform Plan
uses: actions/upload-artifact@v4
with:
name: tfplan
path: terraform/tfplan
- name: Create Plan Summary
if: github.event_name == 'pull_request'
run: |
terraform show -no-color tfplan > plan_output.txt
- name: Comment on PR
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('terraform/plan_output.txt', 'utf8');
const truncatedPlan = plan.length > 65000 ? plan.substring(0, 65000) + "\n\n...(truncated)" : plan;
const output = `#### Terraform Plan 📖
<details><summary>Show Plan</summary>
\`\`\`terraform\n
${truncatedPlan}
\`\`\`
</details>
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`, Exitcode: \`${{ steps.plan.outputs.exitcode }}\`*`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: output
})
terraform-apply:
name: Terraform Apply
if: github.ref == 'refs/heads/main' && needs.terraform-plan.outputs.tfplanExitCode == 2
runs-on: ubuntu-latest
environment: production
needs: [terraform-plan]
defaults:
run:
working-directory: ./terraform
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login (OIDC)
uses: azure/login@v2
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
with:
terraform_version: 1.9.0
- name: Terraform Init
run: terraform init
- name: Download Terraform Plan
uses: actions/download-artifact@v4
with:
name: tfplan
path: terraform/
- name: Terraform Apply
run: terraform apply -auto-approve tfplan
- name: Azure Logout
if: always()
run: az logout
Drift Detection Workflow
Runs on a schedule to detect manual changes:
# .github/workflows/terraform-drift.yml
name: Terraform Drift Detection
on:
schedule:
- cron: '0 8 * * 1-5' # Monday-Friday at 8am UTC
workflow_dispatch:
permissions:
id-token: write
contents: read
issues: write
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_USE_OIDC: true
jobs:
terraform-drift:
name: Check for Drift
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./terraform
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Azure Login (OIDC)
uses: azure/login@v2
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
with:
terraform_version: 1.9.0
- name: Terraform Init
run: terraform init
- name: Terraform Plan (Drift Detection)
id: plan
run: |
terraform plan -detailed-exitcode -no-color || export exitcode=$?
echo "exitcode=$exitcode" >> $GITHUB_OUTPUT
if [ $exitcode -eq 1 ]; then
echo "❌ Terraform plan failed"
exit 1
elif [ $exitcode -eq 2 ]; then
echo "⚠️ Configuration drift detected"
else
echo "✅ No drift detected"
fi
- name: Create GitHub Issue if Drift Detected
if: steps.plan.outputs.exitcode == 2
uses: actions/github-script@v7
with:
script: |
const title = '⚠️ Terraform Configuration Drift Detected';
const body = `Drift has been detected between the Terraform state and actual infrastructure.
**Workflow**: ${{ github.workflow }}
**Run**: ${context.runId}
**Time**: ${new Date().toISOString()}
Please review the [workflow run](${context.payload.repository.html_url}/actions/runs/${context.runId}) for details.`;
github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: title,
body: body,
labels: ['terraform', 'drift', 'infrastructure']
});
Best Practices
1. Store State Remotely
✅ Do:
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "sttfstate"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
❌ Don't: Use local state files in CI/CD
2. Use Workspaces or Separate State Files
# backend.tf - Production
terraform {
backend "azurerm" {
key = "prod.terraform.tfstate"
}
}
# backend.tf - Staging
terraform {
backend "azurerm" {
key = "staging.terraform.tfstate"
}
}
3. Lock State During Apply
terraform {
backend "azurerm" {
# Blob storage automatically locks state
use_msi = false
}
}
4. Use Terraform Variables for Secrets
# In workflow
- name: Terraform Apply
env:
TF_VAR_sql_admin_password: ${{ secrets.SQL_ADMIN_PASSWORD }}
run: terraform apply -auto-approve tfplan
# In Terraform
variable "sql_admin_password" {
description = "SQL Admin Password"
type = string
sensitive = true
}
5. Pin Terraform and Provider Versions
terraform {
required_version = ">= 1.9.0, < 2.0.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
6. Implement Cost Controls
- name: Terraform Cost Estimation
uses: antonbabenko/terraform-cost-estimation@v1
with:
terraform_plan_path: tfplan
currency: USD
Common Use Cases
1. Multi-Environment Deployment
# .github/workflows/terraform-multi-env.yml
name: Multi-Environment Deploy
on:
push:
branches:
- develop # Deploy to dev
- staging # Deploy to staging
- main # Deploy to production
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Determine Environment
id: env
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "environment=production" >> $GITHUB_OUTPUT
echo "tfvars=prod.tfvars" >> $GITHUB_OUTPUT
elif [ "${{ github.ref }}" == "refs/heads/staging" ]; then
echo "environment=staging" >> $GITHUB_OUTPUT
echo "tfvars=staging.tfvars" >> $GITHUB_OUTPUT
else
echo "environment=development" >> $GITHUB_OUTPUT
echo "tfvars=dev.tfvars" >> $GITHUB_OUTPUT
fi
- name: Terraform Apply
run: terraform apply -var-file=${{ steps.env.outputs.tfvars }} -auto-approve
2. Module Testing
# .github/workflows/terraform-module-test.yml
name: Test Terraform Module
on:
pull_request:
paths:
- 'modules/**'
jobs:
terratest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Run Terratest
run: |
cd test
go test -v -timeout 30m
3. Terraform Destroy on PR Close
# .github/workflows/terraform-cleanup.yml
name: Cleanup Test Environment
on:
pull_request:
types: [closed]
jobs:
destroy:
if: startsWith(github.head_ref, 'feature/')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Terraform Destroy
run: |
terraform init
terraform destroy -auto-approve -var="environment=pr-${{ github.event.pull_request.number }}"
Troubleshooting
Workflow Fails: "Error acquiring the state lock"
Cause: Another workflow is running or a previous workflow didn't release the lock
Fix:
# Manually unlock (use with caution)
terraform force-unlock <LOCK_ID>
# Or check Azure Portal > Storage Account > Blob > tfstate (check for .tflock file)
Authentication Failure with OIDC
Error: "Failed to get OIDC token"
Fix:
- Verify federated credentials are created correctly
- Check
permissionsblock in workflow:permissions:
id-token: write
contents: read - Ensure
subjectmatches repo exactly:repo:owner/repo:ref:refs/heads/main
Plan Detects Changes on Every Run
Cause: Non-deterministic Terraform code (timestamps, random values)
Fix:
# Use lifecycle ignore for changing values
resource "azurerm_resource_group" "example" {
name = "rg-example"
location = "eastus"
lifecycle {
ignore_changes = [tags["CreatedOn"]]
}
}