Skip to main content

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?

BenefitDescription
Version ControlInfrastructure code alongside application code
Automated TestingValidate syntax, formatting, and security on every commit
VisibilityPlans attached to PRs for easy review
Audit TrailAll changes tracked in Git history
ApprovalsManual gates before production deployments
ConsistencySame 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

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

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:

  1. Verify federated credentials are created correctly
  2. Check permissions block in workflow:
    permissions:
    id-token: write
    contents: read
  3. Ensure subject matches 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"]]
}
}