GitHub Actions for Terraform
At some point, every enterprise Terraform project ends up with someone manually running applies from their laptop. It's usually framed as "just this once" — a hotfix, a quick environment stand-up — but it becomes the pattern because there's no gate stopping it.
This page covers how to build Terraform pipelines in GitHub Actions that enforce the right workflow: validate on every commit, generate a plan on every PR where reviewers can actually see what's changing, and apply only after approval, only from the right branch.
Key capabilities this covers:
- 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)
The patterns here were built for enterprise workflows — multi-subscription environments, subscription vending pipelines, and situations where the apply gate has to be airtight because bypasses have real consequences.
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
These are the rules I enforce on every CI/CD Terraform setup. None of them are optional in a team environment.
1. Always 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
These are the issues I run into most often when setting up or maintaining these pipelines.
"Error acquiring the state lock"
This happens when another workflow is still running, or a previous run died without releasing the lock. I check the storage account blob container first — if there's a .tflock file sitting there from a failed run, I delete it directly. If the lock ID is in the error output, I force-unlock it:
# Manually unlock (use with caution)
terraform force-unlock <LOCK_ID>
# Or check Azure Portal > Storage Account > Blob > tfstate (look for .tflock file)
Authentication failure with OIDC: "Failed to get OIDC token"
Almost always a misconfigured federated credential. I check three things:
- The federated credential exists and the
subjectmatches the repo and ref exactly:repo:owner/repo:ref:refs/heads/main - The
permissionsblock is in the workflow:permissions:
id-token: write
contents: read - The secret names in GitHub match what the workflow is referencing
Plan detects changes on every run
The usual culprit is non-deterministic code — a tag with a timestamp, a random value computed on each run, or a resource property Azure updates after creation. I use ignore_changes in the lifecycle block to suppress noise from values I don't control:
resource "azurerm_resource_group" "example" {
name = "rg-example"
location = "eastus"
lifecycle {
ignore_changes = [tags["CreatedOn"]]
}
}