Managing infrastructure through Terraform is powerful — but doing it securely and automatically is where real efficiency begins.
In this guide, we’ll set up GitHub Actions workflows that plan and apply Terraform configurations to Azure using OpenID Connect (OIDC) — completely without storing secrets.
We’ll also highlight a critical but often underestimated piece: proper Terraform state storage in Azure Blob Storage.
Feel free: https://github.com/simon-vedder/deploy-terraform-template
Contents
🧭 Architecture Overview
Our setup consists of the following components:
- GitHub Actions Workflows:
- terraform-plan.yml – runs on pull requests, performs validation, security checks, and generates a plan.
- terraform-apply.yml – runs on merge/push, applies the configuration to Azure environments (Dev & Prod).
- Azure App Registration (Service Principal):
- Authenticates via Federated Credentials (OIDC) from GitHub – no secrets required.
- Assigned roles:
- Contributor at the subscription level (to create resources)
- Storage Blob Data Contributor on the storage account (to write Terraform state)
- Terraform Remote State:
- Stored in an Azure Storage Account to ensure consistent, shared state between workflows.
⚙️ Prerequisites
Before automation begins, prepare the following:
- Azure Storage Account
- Create a container (e.g., tfstatefiles) to hold your Terraform state files.
- Azure App Registration
- Create an App Registration in Microsoft Entra ID.
- Add a Federated Credential tied to your GitHub repository and branch (e.g., main).
- Note the following values:
- Client ID
- Tenant ID
- Subscription ID
- Assign Roles
- Contributor on the target Subscription
- Storage Blob Data Contributor on the Storage Account
- GitHub Secrets Store only the IDs (no client secret!):
- ARM_CLIENT_ID
- ARM_TENANT_ID
- ARM_SUBSCRIPTION_ID
🧩 Terraform Configuration
Terraform itself is configured to use OIDC and store its state securely in Azure Blob Storage.
backend.tf – configure the location of your statefiles:
terraform {
backend "azurerm" {
use_oidc = true
resource_group_name = "rg" #resource group which is associated with the storage account
storage_account_name = "thisisatestsaacc" #storage account name
container_name = "tfstatefiles" #container name
key = "dev/terraform.tfstate" #path and file name
}
}
💡 Why this matters:
Keeping your Terraform state in a shared, remote backend ensures team consistency and prevents state corruption.
Storing it in Azure Blob Storage allows your pipelines to work concurrently and recover easily if a local runner fails.
main.tf (excerpt):
provider "azurerm" {
features {}
use_oidc = true
}
resource "azurerm_resource_group" "main" {
name = "demo-dev-rg"
location = "West Europe"
}
resource "azurerm_virtual_network" "main" {
name = "vnet-demo-dev"
address_space = ["10.0.0.0/16"]
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
}

🚀 GitHub Actions Workflows
Your automation is split into two workflows: Plan and Apply.
Both use OIDC for authentication and share the same Terraform backend.
1. Terraform Plan Workflow
📄 File: .github/workflows/terraform-plan.yml (Full code: Link)
Triggered by pull requests to the main branch.
Key highlights:
- Runs terraform init, validate, and plan for each environment.
- Performs security scans using Checkov and tfsec.
- Comments plan results directly in the pull request.
permissions:
id-token: write # Required for OIDC
contents: read
pull-requests: write
- name: Azure Login via OIDC
uses: azure/login@v1
with:
client-id: ${{ secrets.ARM_CLIENT_ID }}
tenant-id: ${{ secrets.ARM_TENANT_ID }}
subscription-id: ${{ secrets.ARM_SUBSCRIPTION_ID }}



2. Terraform Apply Workflow
📄 File: .github/workflows/terraform-apply.yml (Full code: Link)
Triggered on push to the main branch or manually via workflow_dispatch.
Key features:
- Applies Terraform to Dev first, then Prod.
- Includes a manual approval step before deploying to production.
- Uses the same OIDC login, no secrets required.
manual-approval:
name: Manual Approval
uses: trstringer/manual-approval@v1
with:
approvers: simon-vedder #user account of approvers
minimum-approvals: 1
issue-title: "Manual Approval Required for Terraform Apply"
What happens when an error occurs:

How it should look like:



🔐 Secure Authentication with OIDC
This setup uses Federated Credentials (OIDC) between GitHub and Azure — which means:
- ✅ No static secrets (no ARM_CLIENT_SECRET)
- ✅ Short-lived tokens automatically managed by Azure
- ✅ Fine-grained access scoped to your repository and branch
💡 Pro tip:
Federated credentials can be configured per branch or environment, letting you enforce least privilege and safer multi-environment pipelines.
GitHub Environsment Configuration:


Azure App Registration Federated Credentials Trust with GitHub:


🛡️ Security Checks for Terraform Code
Before applying any infrastructure changes, each workflow runs automated security scans to detect misconfigurations and policy violations early.
Tools used:
- Checkov – focuses on Terraform best practices and compliance checks
- tfsec (optional alternative) – static analysis for Terraform code security
Both scan your .tf files directly in the pipeline and flag potential risks such as:
- Publicly exposed network ports
- Unencrypted storage or disks
- Missing tags or resource policies
- Use of deprecated Terraform resources or Azure SKUs
Implementation:
- name: Terraform Validate
run: terraform validate
working-directory: terraform/environments/${{ matrix.environment }}
- name: Terraform Format Check
run: terraform fmt -check -recursive
working-directory: terraform/environments/${{ matrix.environment }}
- name: Security Scan - Checkov
run: checkov -d . --framework terraform --output cli --skip-check CKV_AZURE_50
continue-on-error: true
working-directory: terraform/environments/${{ matrix.environment }}
- name: Security Scan - TFSec
run: tfsec .
continue-on-error: true
working-directory: terraform/environments/${{ matrix.environment }}
💡 Tip:
You can and should set continue-on-error: false for production to fail the pipeline if a critical issue is found.
This approach enforces security gates before deployments and keeps your IaC compliant without manual reviews.
🧱 Terraform State Management (the underrated part)
Many CI/CD guides forget to mention where Terraform’s state files live — yet it’s crucial.
To achieve this you just have to prepare the backend.tf like in the example above.
With a remote backend in Azure Blob Storage:
- You get consistent state across all runs.
- GitHub Actions can safely read/write the same state concurrently.
- The App Registration’s Storage Blob Data Contributor role ensures secure, limited access.
- If one pipeline fails, state recovery is trivial — no local corruption, no drift.
🔒 Best practice:
Never store terraform.tfstate locally or in your repository.
Always rely on a secure, shared backend like Azure Blob Storage.


✅ Results
After a successful terraform apply
- Azure Portal shows your created infrastructure:
- Resource Group
- Virtual Network
- Network Security Group
- Windows VM

🧩 Benefits of This Setup
Aspect | Benefit |
Security | No secrets stored in GitHub; OIDC provides temporary credentials |
Consistency | Centralized remote state in Azure Blob Storage |
Automation | Full CI/CD flow from PR to production |
Safety | Manual approval before production |
Compliance | Built-in Terraform validation and security scanning |
🧠 Final Thoughts
By combining GitHub Actions, Azure OIDC, and Terraform remote state, you achieve a workflow that is:
- Secure by design
- Fully automated
- Transparent and auditable
- Easy to extend for multi-environment or multi-subscription setups
This pattern should be your default baseline whenever you deploy Terraform to Azure from GitHub — simple, maintainable, and entirely secret-free.