Skip to main content

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

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:

  1. GitHub Repository > Settings > Environments > New environment
  2. Name: production
  3. Protection rules:
    • ✅ Required reviewers (add approvers)
    • ✅ Wait timer: 0 minutes
    • ✅ Deployment branches: main only

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:

  1. The federated credential exists and the subject matches the repo and ref exactly: repo:owner/repo:ref:refs/heads/main
  2. The permissions block is in the workflow:
    permissions:
    id-token: write
    contents: read
  3. 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"]]
}
}