Deploying Terraform to Azure with GitHub Actions and OIDC
Infrastructure changes should be repeatable without giving up control. This guide shows how to automate Terraform deployments to Azure with GitHub Actions and temporary cloud access.

As applications grow, infrastructure changes become more frequent. Repeating those changes by hand takes time and makes it easy to miss a setting or configure environments differently. The goal is to make delivery repeatable and faster, while keeping control over what changes and who can deploy it.
Infrastructure as Code turns those settings into version-controlled configuration. Terraform describes the infrastructure we want; GitHub Actions provides a shared process for checking, reviewing and applying changes. Instead of relying on someone remembering the right commands, each change follows the same path.
That process also needs access to Azure. Storing a reusable client secret in GitHub creates another credential to protect and rotate. OpenID Connect, or OIDC, lets an authorised job exchange its GitHub identity for temporary Azure access instead. The trust relationship defines which jobs may authenticate; Azure permissions still define what they may do.[1]
To put these pieces together, imagine an online store where a customer submits an order at checkout. The store places an order event on a queue, so a separate worker can process it. We will manage that queue with Terraform, check proposed changes against Dev, deploy there automatically after merge, and promote the same code through Stage and Production with separate approvals for plan and apply.

One commit moves through all three environments. Each environment gets its own plan against its own state.
What are we setting up?
We are building a Terraform delivery pipeline for an Azure Service Bus namespace and order-events queue across separate Dev, Stage and Production subscriptions. Each environment has its own state storage and deployment identity. GitHub Actions controls the process; OIDC supplies temporary access.
This is the Azure companion to Deploying Terraform to AWS with GitHub Actions and OIDC. The promotion model stays the same, with Azure-specific identity, state access and queue behaviour.
The setup has three parts:
- Terraform: the workload definition, plus a separate one-time foundation for each environment.
- Azure access: one user-assigned managed identity per subscription, shared by that environment's plan and apply jobs.
- GitHub Actions: trusted PRs plan against Dev; merging into
maindeploys Dev automatically, followed by gated Stage and Production deployments.
This example uses three existing Azure subscriptions associated with one existing Microsoft Entra tenant, Azure public cloud, and West Europe. A managed identity is an Azure-managed security identity; here, a federated credential lets GitHub use it without attaching the runner to an Azure VM.[2]
What do you need before starting?
You need basic Terraform/YAML knowledge, repository administration rights, and an approved administrator Azure CLI login for each subscription. The administrator must be able to create resource groups, storage, managed identities and federated credentials, and assign Azure roles. We are not creating subscriptions or a tenant.
Have an administrator register Microsoft.Storage, Microsoft.ManagedIdentity and Microsoft.ServiceBus in each subscription. The examples disable automatic resource-provider registration, so routine deployment identities do not need subscription-wide registration rights.[3]
This guide assumes a private repository with trusted contributors and a GitHub plan that supports required environment reviewers for private repositories. GitHub Free, Pro and Team offer required reviewers only for public repositories; check your organisation's capabilities before building around these gates. Do not make sensitive plans public to obtain them.[4]
1. Define the infrastructure
Dev, Stage and Production each have a separate subscription, Service Bus namespace and queue, state storage account/container, and deployment identity. Azure RBAC permissions remain scoped separately within the shared tenant.
Separate each environment's administrator-controlled foundation from the resources the pipeline manages. Bootstrap creates two resource groups: store-foundation-<environment> for identity and state, and store-<environment> as the workload boundary. The workload configuration creates the namespace and queue inside that second group; it does not manage its own identity or role assignments.

Bootstrap owns each foundation and creates the workload resource group. The pipeline manages the namespace and queue. Bootstrap state stays on the administrator's workstation, separately for each subscription.
Keep this code in a dedicated store-infra repository, separate from the application. .github/workflows/ contains the checks and deployments. bootstrap/ creates each subscription's foundation locally once; terraform/ holds the workload and its environment-specific inputs and backend files.
The complete example is available below. Work from its store-infra/ directory; the workflows remain inactive until you copy that directory's contents into your own separately configured private repository.
https://github.com/ferhaty/terraform-azure-github-actions-oidc
store-infra/
├── .gitignore
├── .github/workflows/
│ ├── pr.yml
│ ├── deploy.yml
│ └── environment.yml
├── bootstrap/
│ ├── versions.tf
│ ├── state.tf
│ ├── identity.tf
│ └── .terraform.lock.hcl
└── terraform/
├── versions.tf
├── variables.tf
├── main.tf
├── .terraform.lock.hcl
├── tests/queue.tftest.hcl
└── vars/
├── dev.tfvars
├── dev.backend.hcl
├── stage.tfvars
├── stage.backend.hcl
├── production.tfvars
└── production.backend.hcl
Use a repository .gitignore to keep local state, provider downloads and saved plans out of commits:
**/.terraform/*
*.tfstate
*.tfstate.*
*.tfplan
plan/
crash.log
*.auto.tfvars
Define the namespace and queue
In terraform/versions.tf, pin Terraform and the AzureRM provider, then declare the Azure Blob backend:
terraform {
required_version = "= 1.17.1"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "= 5.12.0"
}
}
backend "azurerm" {}
}
provider "azurerm" {
features {}
resource_provider_registrations = "none"
}
The provider manages Azure resources. The backend stores Terraform state. Each requires its own authentication configuration.[5][3]
Use terraform/variables.tf for the environment and globally unique namespace name:
variable "environment" {
type = string
validation {
condition = contains(["dev", "stage", "production"], var.environment)
error_message = "Choose dev, stage or production."
}
}
variable "namespace_name" {
type = string
description = "Globally unique Service Bus namespace name; replace the example value."
validation {
condition = can(regex("^[a-z][a-z0-9-]{4,48}[a-z0-9]$", var.namespace_name))
error_message = "Use 6-50 lowercase letters, digits or hyphens; start with a letter and end with a letter or digit."
}
}
In terraform/main.tf, create the workload inside the resource group that bootstrap prepares:
resource "azurerm_servicebus_namespace" "store" {
name = var.namespace_name
location = "westeurope"
resource_group_name = "store-${var.environment}"
sku = "Standard"
local_auth_enabled = false
minimum_tls_version = "1.2"
tags = {
Service = "store"
Environment = var.environment
ManagedBy = "Terraform"
}
}
resource "azurerm_servicebus_queue" "orders" {
name = "order-events-${var.environment}"
namespace_id = azurerm_servicebus_namespace.store.id
default_message_ttl = "P4D"
lock_duration = "PT1M"
max_delivery_count = 10
dead_lettering_on_message_expiration = true
}
output "queue_id" {
value = azurerm_servicebus_queue.orders.id
}
Service Bus has its own message lifecycle. P4D sets the default message time-to-live to four days. Senders can specify a shorter TTL, and expired messages are not delivered normally. This queue sends expired messages to its dead-letter subqueue.[6][7]
PT1M gives a receiver a one-minute PeekLock to process a message before settling it. A successful consumer completes the message; an abandoned message or an expired lock can lead to another delivery. The delivery limit is ten. Consumers must handle redelivery and monitor/drain the dead-letter subqueue.[6][8]
Size the namespace and tune these queue settings for your workload before production use. local_auth_enabled = false disables SAS authentication. Application producers and consumers need their own Entra identities and appropriate Service Bus data roles, separate from the infrastructure deployment identity.[9]
Give each environment its own inputs and state
Create the following files under terraform/vars/. Replace every example namespace and storage account name with your own globally unique names. Storage account names allow 3–24 lowercase letters or digits; namespace names have different rules. Passing a local naming check does not prove global availability.[10]
dev.tfvars:
environment = "dev"
namespace_name = "store-dev-replace-me"
stage.tfvars:
environment = "stage"
namespace_name = "store-stage-replace-me"
production.tfvars:
environment = "production"
namespace_name = "store-production-replace-me"
dev.backend.hcl:
storage_account_name = "replacedevstate"
container_name = "tfstate"
key = "store/dev/terraform.tfstate"
use_oidc = true
use_azuread_auth = true
stage.backend.hcl:
storage_account_name = "replacestagestate"
container_name = "tfstate"
key = "store/stage/terraform.tfstate"
use_oidc = true
use_azuread_auth = true
production.backend.hcl:
storage_account_name = "replaceproductionstate"
container_name = "tfstate"
key = "store/production/terraform.tfstate"
use_oidc = true
use_azuread_auth = true
The .tfvars file supplies workload settings to plan; .backend.hcl selects the state location during init. The GitHub environment supplies the identity and subscription IDs. Separate subscriptions, storage accounts and scoped Azure roles provide environment isolation.
The backend uses Microsoft Entra authentication for the Blob data plane, not a storage account key or SAS token. use_oidc = true enables federation; use_azuread_auth = true ensures the backend uses Entra authorisation instead of retrieving a storage key. Standard storage endpoints let it address the container directly, without management-plane endpoint lookup.[5]
Azure Blob leases provide Terraform's backend locking. State contains the relationship between configuration and managed resources, and can include sensitive values. Keep state and saved plans out of Git; protect the container and restrict workflow logs and artifacts.[5][11]
Prepare the foundation locally
Create all the files in this guide, including the bootstrap reference below, before running these commands. Generate and commit both provider locks:
terraform -chdir=bootstrap providers lock -platform=linux_amd64 -platform=darwin_arm64
terraform -chdir=terraform providers lock -platform=linux_amd64 -platform=darwin_arm64
linux_amd64 covers the Ubuntu runners; darwin_arm64 is an Apple Silicon workstation example. Replace it with your workstation's platform, or add another -platform argument. CI will use -lockfile=readonly rather than changing the lock during a deployment.[12]
Bootstrap deliberately has no remote backend block: its initial state is local. Use a clean working directory and separate secured state for each subscription. Run these three command groups separately in the same Bash shell, after replacing the IDs and names. The fail-fast setting stops on errors and may close an interactive shell.
First, select and inspect the administrator's Dev context:
# From store-infra, after your approved Dev administrator Azure CLI login:
set -euo pipefail
export ARM_SUBSCRIPTION_ID="REPLACE-WITH-DEV-SUBSCRIPTION-ID"
export ARM_TENANT_ID="REPLACE-WITH-TENANT-ID"
export ARM_USE_CLI=true ARM_USE_OIDC=false
az account set --subscription "$ARM_SUBSCRIPTION_ID"
az account show --query '{subscription:id,tenant:tenantId,name:name}'
Confirm that both the subscription and tenant are the intended Dev context before continuing. The workflow's OIDC identity is not used for this administrator step.
Then initialise and plan the foundation:
umask 077
mkdir "$HOME/store-foundation-dev"
cp bootstrap/*.tf bootstrap/.terraform.lock.hcl "$HOME/store-foundation-dev/"
terraform -chdir="$HOME/store-foundation-dev" init -lockfile=readonly
terraform -chdir="$HOME/store-foundation-dev" plan -out=foundation.tfplan -var="subscription_id=$ARM_SUBSCRIPTION_ID" -var="tenant_id=$ARM_TENANT_ID" -var='environment=dev' -var='state_account_name=replacedevstate' -var='github_repository_subject=example-org@123/store-infra@456'
terraform -chdir="$HOME/store-foundation-dev" show foundation.tfplan
Review the resources and role scopes in that plan. Only after accepting them, run the apply group; applying a saved plan does not ask for another confirmation:
terraform -chdir="$HOME/store-foundation-dev" apply foundation.tfplan
Repeat for Stage and Production with their own subscription IDs, environment values, globally unique state account names and new directories (store-foundation-stage and store-foundation-production). Keep the common tenant and repository identity consistent. Never switch subscriptions over another environment's existing local state.
If a step fails after creating the directory, retain it and its state. Recheck the administrator context, then run init and a fresh plan there; do not delete the directory or repeat the initial copy to start over. Secure and back up each foundation state and plan separately. The workflow identity receives no access to those local files.
After bootstrap, put each state_account_name output in the matching backend file. Keep the foundation account and workload namespace names distinct. State storage is created before CI needs it; bootstrap is never part of the workflow.
2. Connect GitHub to Azure with OIDC
OIDC replaces a stored client secret with a trust relationship. GitHub issues a signed, short-lived assertion identifying the job. Microsoft Entra validates that assertion against a federated credential on the managed identity, then issues an access token for the requested Azure API.[2][1]

The GitHub assertion audience is api://AzureADTokenExchange. The provider and backend use Azure API-specific access tokens—not that GitHub assertion—to manage the workload and access state.
Why not store a client secret?
A stored client secret is reusable until it expires or is revoked, and replacements must reach every system using it. Federation removes that long-lived credential from GitHub. Each authorised job requests temporary access based on its identity, without a client secret to distribute.[1]
A compromised workflow can misuse its assigned permissions while its token is valid, which may extend beyond job completion. Azure roles determine those permissions independently of OIDC authentication. Protect workflow changes, Terraform code, runners and approval rules.
Access is controlled at three levels:
- Federated credential: which issuer, audience and subject may authenticate as this managed identity.
- Azure RBAC: what that identity can do, and at which resource scopes.
- GitHub environment rules: when a job can start and which branches may use that environment.
id-token: write permits the job to request a GitHub OIDC token. It grants no Azure role by itself.[13]
Trust the exact repository and environment
This example assumes GitHub immutable subject claims. Check the repository's OIDC settings and use its actual owner/repository names and numeric IDs. For the illustrative identity example-org@123/store-infra@456, the exact subjects are:
GitHub environment — Federated subject
dev, including trusted PR planningrepo:example-org@123/store-infra@456:environment:devstagerepo:example-org@123/store-infra@456:environment:stageproductionrepo:example-org@123/store-infra@456:environment:production
An environment replaces the branch or PR context in the subject; it is not appended to it. That means the federated subject alone does not impose a main branch restriction—GitHub's environment rules must do that.[13]
Microsoft Entra accepts an explicitly configured subject string and requires an exact match; no wildcard is needed. The Terraform resource below supplies that string directly, rather than relying on a portal form to infer it. Use the issuer https://token.actions.githubusercontent.com and audience api://AzureADTokenExchange exactly.[2]
In bootstrap/identity.tf, create the identity, trust and two role assignments:
resource "azurerm_user_assigned_identity" "terraform" {
name = "store-terraform-${var.environment}"
location = azurerm_resource_group.foundation.location
resource_group_name = azurerm_resource_group.foundation.name
}
resource "azurerm_federated_identity_credential" "github" {
name = "github-${var.environment}"
user_assigned_identity_id = azurerm_user_assigned_identity.terraform.id
issuer = "https://token.actions.githubusercontent.com"
audience = ["api://AzureADTokenExchange"]
subject = "repo:${var.github_repository_subject}:environment:${var.environment}"
}
resource "azurerm_role_assignment" "workload" {
scope = azurerm_resource_group.workload.id
role_definition_name = "Contributor"
principal_id = azurerm_user_assigned_identity.terraform.principal_id
principal_type = "ServicePrincipal"
}
resource "azurerm_role_assignment" "state" {
scope = azurerm_storage_container.state.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = azurerm_user_assigned_identity.terraform.principal_id
principal_type = "ServicePrincipal"
}
output "client_id" {
value = azurerm_user_assigned_identity.terraform.client_id
}
output "tenant_id" {
value = var.tenant_id
}
output "subscription_id" {
value = var.subscription_id
}
There is one identity per environment, not a separate read-only plan principal. Both plan and apply—and Dev PR planning—receive the same deployment-capable permissions.
Contributor is scoped to the workload resource group, not the subscription. It can manage and delete that group's resources, including the group itself, but it does not grant permission to assign Azure roles. This role covers all resource types within the workload group; a custom role can narrow it to Service Bus operations. Keep the group dedicated to this workload.[14]
Storage Blob Data Contributor is scoped separately to that environment's state container. Management-plane Contributor alone does not provide the backend's Blob data access. The Blob role also permits deletion of that state container. The deployment identity cannot modify the foundation resource group itself, its managed identity, federated credential or role assignments.[5][15]
New identities, federated credentials and role assignments can take time to propagate. If the first exchange or backend access fails, confirm the exact tenant/client IDs, subject and role scope, then allow propagation and retry the failed setup step. Do not fix a propagation delay by granting subscription-wide Owner access.
Use native Terraform OIDC authentication
The workflows below use the AzureRM provider and AzureRM backend's native GitHub OIDC support. They discover the job's ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, request the GitHub assertion, and perform the Entra exchange. No azure/login step or manually copied token is required.[5][3]
Both use the managed identity's client ID and tenant ID. The provider obtains access for Azure Resource Manager; the backend obtains access for Azure Storage. api://AzureADTokenExchange is the audience of the incoming GitHub assertion, not a universal Azure API access-token audience. Adding azure/login would be a different authentication path, not a substitute for configuring the backend.
Configure the GitHub environments
Create dev, stage and production before running the workflows. A job referencing a missing environment can create one without your intended protection rules, so the YAML alone is not the approval configuration.[16]
- Dev: no required reviewers. Under selected deployment branch rules, allow
mainandrefs/pull/*/merge, so trusted PRs can plan and merged changes can deploy automatically. - Stage and Production: allow only
main, enable required reviewers, and disable self-review and administrator bypass where supported. Each environment requires approval before its plan job and a separate approval before its dependent apply job. - Repository: protect
mainwith PR review and passing checks, prevent direct pushes, and require review of workflow and Terraform changes. - Forks: do not enable write tokens or secrets for fork PR workflows, and do not use
pull_request_targetto execute proposed code.[16][17]
Create these GitHub environment secrets in each of dev, stage and production under Settings → Environments → Environment secrets:
| Secret | Value from that environment's bootstrap |
|---|---|
AZURE_CLIENT_ID |
client_id |
AZURE_TENANT_ID |
tenant_id |
AZURE_SUBSCRIPTION_ID |
subscription_id |
The workflows read these environment secrets and map them to ARM_CLIENT_ID, ARM_TENANT_ID and ARM_SUBSCRIPTION_ID for Terraform. West Europe is set directly in the HCL.
For simplicity, the example uses public Azure service endpoints protected by Entra authorisation. Disabling anonymous Blob access restricts authorisation while leaving the public network endpoint available. If your policy requires private endpoints, supply runners with the necessary network access and design that separately.
3. Check pull requests and plan against Dev
The PR workflow has one plan job. It checks formatting, initialises Dev's real Blob backend using OIDC, validates and mock-tests the workload, and plans against Dev. Bootstrap remains outside CI.
Planning executes Terraform configuration and provider code with Dev's deployment-capable identity and reads its state. This path is only for trusted same-repository contributors. The guard excludes public repositories, forks and untrusted author associations, but it is not a sandbox against a hostile collaborator.[17]
Use .github/workflows/pr.yml:
name: Terraform PR checks
on:
pull_request:
branches: [main]
permissions:
contents: read
env:
TF_IN_AUTOMATION: 'true'
TF_INPUT: 'false'
jobs:
plan:
if: >-
github.event.repository.private == true &&
github.event.pull_request.head.repo.id == github.event.repository.id &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.pull_request.author_association) &&
github.actor != 'dependabot[bot]'
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: dev
permissions:
contents: read
id-token: write
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_USE_OIDC: 'true'
ARM_USE_AZUREAD: 'true'
ARM_USE_CLI: 'false'
concurrency:
group: terraform-store-azure-dev
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.17.1
terraform_wrapper: false
- name: Format
run: terraform -chdir=terraform fmt -check -recursive
- name: Initialize Dev backend
run: terraform -chdir=terraform init -input=false -lockfile=readonly -backend-config=vars/dev.backend.hcl
- name: Validate
run: terraform -chdir=terraform validate
- name: Mock tests
run: terraform -chdir=terraform test
- name: Plan against Dev
run: terraform -chdir=terraform plan -input=false -no-color -lock-timeout=5m -var-file=vars/dev.tfvars
Init authenticates to the backend before accessing Dev state; plan authenticates the AzureRM provider for resource operations. The PR plan uses the proposed merge revision and appears in the restricted job log. It is review feedback, not a plan to apply after merge.
Commit terraform/.terraform.lock.hcl and keep readonly init in CI. TF_INPUT=false disables input prompts; TF_IN_AUTOMATION only adjusts Terraform's output for automation.
terraform/tests/queue.tftest.hcl checks all three environment mappings with a mock provider:
mock_provider "azurerm" {
mock_resource "azurerm_servicebus_namespace" {
defaults = {
id = "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/mock/providers/Microsoft.ServiceBus/namespaces/mock-namespace"
}
}
}
run "queue_dev" {
command = plan
variables {
environment = "dev"
namespace_name = "store-dev-mock"
}
assert {
condition = azurerm_servicebus_namespace.store.resource_group_name == "store-dev" && azurerm_servicebus_queue.orders.name == "order-events-dev"
error_message = "Workload and queue must belong to the selected environment."
}
assert {
condition = !azurerm_servicebus_namespace.store.local_auth_enabled && azurerm_servicebus_queue.orders.default_message_ttl == "P4D" && azurerm_servicebus_queue.orders.lock_duration == "PT1M" && azurerm_servicebus_queue.orders.max_delivery_count == 10 && azurerm_servicebus_queue.orders.dead_lettering_on_message_expiration
error_message = "Keep Entra authentication and the intended message lifecycle settings."
}
}
run "queue_stage" {
command = plan
variables {
environment = "stage"
namespace_name = "store-stage-mock"
}
assert {
condition = azurerm_servicebus_namespace.store.resource_group_name == "store-stage" && azurerm_servicebus_queue.orders.name == "order-events-stage"
error_message = "Workload and queue must belong to the selected environment."
}
assert {
condition = !azurerm_servicebus_namespace.store.local_auth_enabled && azurerm_servicebus_queue.orders.default_message_ttl == "P4D" && azurerm_servicebus_queue.orders.lock_duration == "PT1M" && azurerm_servicebus_queue.orders.max_delivery_count == 10 && azurerm_servicebus_queue.orders.dead_lettering_on_message_expiration
error_message = "Keep Entra authentication and the intended message lifecycle settings."
}
}
run "queue_production" {
command = plan
variables {
environment = "production"
namespace_name = "store-production-mock"
}
assert {
condition = azurerm_servicebus_namespace.store.resource_group_name == "store-production" && azurerm_servicebus_queue.orders.name == "order-events-production"
error_message = "Workload and queue must belong to the selected environment."
}
assert {
condition = !azurerm_servicebus_namespace.store.local_auth_enabled && azurerm_servicebus_queue.orders.default_message_ttl == "P4D" && azurerm_servicebus_queue.orders.lock_duration == "PT1M" && azurerm_servicebus_queue.orders.max_delivery_count == 10 && azurerm_servicebus_queue.orders.dead_lettering_on_message_expiration
error_message = "Keep Entra authentication and the intended message lifecycle settings."
}
}
Mock providers supply simulated resource data for these configuration checks.[18]
4. Deploy Dev, then promote through Stage and Production
A merge triggers a push to main. Dev plans and applies automatically. After Dev succeeds, Stage waits for approval to plan, then for a second approval to apply that saved plan. Production follows the same sequence after Stage succeeds. All jobs use the same commit; every environment gets a new plan against its own subscription, inputs and state.
Use .github/workflows/deploy.yml to define that sequence:
name: Terraform Deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
dev:
if: github.ref == 'refs/heads/main'
uses: ./.github/workflows/environment.yml
with:
environment: dev
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
stage:
needs: dev
uses: ./.github/workflows/environment.yml
with:
environment: stage
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
production:
needs: stage
uses: ./.github/workflows/environment.yml
with:
environment: production
secrets:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
The local reusable workflow avoids repeating the same jobs three times. The caller passes the three named secrets; each plan and apply job selects its environment to receive that environment’s values. In .github/workflows/environment.yml, both jobs reference the same target environment, and apply depends on plan:
name: Terraform Environment
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
AZURE_CLIENT_ID:
required: true
AZURE_TENANT_ID:
required: true
AZURE_SUBSCRIPTION_ID:
required: true
permissions:
contents: read
id-token: write
env:
TF_IN_AUTOMATION: 'true'
TF_INPUT: 'false'
TARGET_ENV: ${{ inputs.environment }}
jobs:
plan:
if: >-
github.ref == 'refs/heads/main' &&
contains(fromJSON('["dev","stage","production"]'), inputs.environment)
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: ${{ inputs.environment }}
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_USE_OIDC: 'true'
ARM_USE_AZUREAD: 'true'
ARM_USE_CLI: 'false'
concurrency:
group: terraform-store-azure-${{ inputs.environment }}
cancel-in-progress: false
outputs:
artifact_id: ${{ steps.upload.outputs.artifact-id }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.17.1
terraform_wrapper: false
- name: Format
run: terraform -chdir=terraform fmt -check -recursive
- name: Initialize environment backend
run: terraform -chdir=terraform init -input=false -lockfile=readonly -backend-config="vars/$TARGET_ENV.backend.hcl"
- name: Validate
run: terraform -chdir=terraform validate
- name: Mock tests
run: terraform -chdir=terraform test
- name: Create saved plan
run: |
mkdir -p plan
terraform -chdir=terraform plan -input=false -no-color -lock-timeout=5m -var-file="vars/$TARGET_ENV.tfvars" -out=../plan/tfplan
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
id: upload
with:
name: tfplan-${{ inputs.environment }}-${{ github.run_id }}-${{ github.run_attempt }}
path: plan/tfplan
retention-days: 1
if-no-files-found: error
overwrite: false
apply:
needs: plan
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 15
environment: ${{ inputs.environment }}
env:
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
ARM_USE_OIDC: 'true'
ARM_USE_AZUREAD: 'true'
ARM_USE_CLI: 'false'
concurrency:
group: terraform-store-azure-${{ inputs.environment }}
cancel-in-progress: false
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
with:
terraform_version: 1.17.1
terraform_wrapper: false
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
artifact-ids: ${{ needs.plan.outputs.artifact_id }}
path: plan/
merge-multiple: true
digest-mismatch: error
- name: Initialize matching backend
run: terraform -chdir=terraform init -input=false -lockfile=readonly -backend-config="vars/$TARGET_ENV.backend.hcl"
- name: Apply only this environment's saved plan
run: terraform -chdir=terraform apply -input=false -lock-timeout=5m ../plan/tfplan
Approve Stage or Production planning first. When that plan job finishes, read the proposed changes directly in its GitHub Actions log before approving apply. The artifact contains only the binary tfplan.
Apply checks out the same commit, downloads the exact artifact ID produced by its plan job in the same workflow run, initialises the matching backend and applies that saved plan. It does not generate a replacement plan after approval. Dev follows the same implementation without manual gates.
The run attempt in the artifact name avoids collisions with an earlier attempt. Never apply a PR plan, a different environment's plan or an artifact selected merely by a familiar filename. Saved plans can contain sensitive values, so keep the repository, job logs and artifacts restricted; the example retains the plan for one day.[11]
How do you retry a failed promotion?
Restart at the chosen environment's plan job in the existing workflow run. GitHub can rerun that job and its dependents while retaining the original commit and ref; the completed upstream environments need not run again. Stage and Production keep their plan and apply approval gates.[19]
A Dev-plan rerun can continue through Stage and Production; a Stage-plan rerun can continue through Production; a Production-plan rerun stays there. Select the actual plan job in the expanded reusable workflow. Use Run workflow from main when you want a new run using its selected revision, starting at Dev instead. Check the intended revision before rerunning: a fresh plan from older code can propose reversing newer infrastructure changes.
If the plan expired, is missing or stale, or apply partially changed resources, make a fresh plan and review it again before applying. Each new plan supplies its own artifact ID to its dependent apply job.
5. Handle overlapping changes and failures
The actual jobs use concurrency groups terraform-store-azure-dev, terraform-store-azure-stage and terraform-store-azure-production. PR planning shares Dev's group with Dev deployment jobs. The reusable-workflow caller does not hold the same group, avoiding nested lock ownership.
cancel-in-progress: false avoids interrupting an active apply. GitHub concurrency is repository-scoped and is not a guaranteed FIFO queue; pending work can be superseded. Blob leases remain Terraform's state-level safeguard.[20][5]
Neither mechanism reserves state across an approval gap. The plan job releases its concurrency group and state lock when it finishes, so another run can change state while approval waits. Terraform rejects a saved plan whose state is stale. Direct Azure changes are not prevented by a Terraform lock either: regenerate and review a plan when state or infrastructure changes.
A failed apply may leave partial changes. Inspect the actual state and resources, correct the cause, and plan again. Restoring an old state file is not an infrastructure rollback. Only force-unlock after confirming the operation that held the lock is no longer running.
For drift detection, a separate trusted workflow can run terraform plan -detailed-exitcode without an apply job: 0 means no changes, 1 an error and 2 proposed changes. A job using Stage or Production still encounters that environment's approval rules. Decide whether to change the code or correct the resource rather than automatically reverting every difference.[11]
Reference: complete the local bootstrap
These files complete the foundation used by the earlier commands. Combine them with bootstrap/identity.tf, reuse the configuration for each subscription, and keep its state separate. The deployment workflows never apply these files.
bootstrap/versions.tf:
terraform {
required_version = "= 1.17.1"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "= 5.12.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
tenant_id = var.tenant_id
resource_provider_registrations = "none"
storage_use_azuread = true
}
variable "subscription_id" {
type = string
}
variable "tenant_id" {
type = string
}
variable "environment" {
type = string
validation {
condition = contains(["dev", "stage", "production"], var.environment)
error_message = "Choose dev, stage or production."
}
}
variable "state_account_name" {
type = string
validation {
condition = can(regex("^[a-z0-9]{3,24}$", var.state_account_name))
error_message = "Use a globally unique storage account name: 3-24 lowercase letters or digits."
}
}
variable "github_repository_subject" {
type = string
description = "Immutable repository identity without repo: or environment suffix; e.g. example-org@123/store-infra@456."
validation {
condition = can(regex("^[A-Za-z0-9_.-]+@[0-9]+/[A-Za-z0-9_.-]+@[0-9]+$", var.github_repository_subject))
error_message = "Use owner and repository names with immutable numeric IDs."
}
}
bootstrap/state.tf:
resource "azurerm_resource_group" "foundation" {
name = "store-foundation-${var.environment}"
location = "westeurope"
}
resource "azurerm_resource_group" "workload" {
name = "store-${var.environment}"
location = "westeurope"
}
resource "azurerm_storage_account" "state" {
name = var.state_account_name
resource_group_name = azurerm_resource_group.foundation.name
location = azurerm_resource_group.foundation.location
account_tier = "Standard"
account_replication_type = "LRS"
account_kind = "StorageV2"
min_tls_version = "TLS1_2"
https_traffic_only_enabled = true
shared_access_key_enabled = false
allow_nested_items_to_be_public = false
default_to_oauth_authentication = true
blob_properties {
versioning_enabled = true
delete_retention_policy {
days = 7
}
}
lifecycle {
prevent_destroy = true
}
}
resource "azurerm_storage_container" "state" {
name = "tfstate"
storage_account_id = azurerm_storage_account.state.id
container_access_type = "private"
}
output "state_account_name" {
value = azurerm_storage_account.state.name
}
The state account uses private containers, HTTPS, disabled Shared Key access, Blob versioning and a seven-day deleted-blob retention policy. LRS is a simple redundancy choice, not a cross-region recovery design. prevent_destroy guards deletion through this configuration; it is not an independent Azure deletion lock.
Bootstrap's resource-management permissions create the account, container and role assignments. CI then uses its separate Blob data role for state operations. Keep administrator recovery access and backups outside routine deployment permissions.
Frequently asked questions
Do I need an app registration or a client secret?
This implementation uses a user-assigned managed identity with a federated credential. An app registration is an alternative way to support federation. No client secret or storage key is passed to GitHub.[2]
Is the plan identity read-only?
No. Plan and apply share one deployment identity per environment. Even a PR plan has Dev's workload-management and state permissions. Trusted contributors, protected workflow code and environment gates are part of the security boundary.
Why does Blob access fail when workload deployment is allowed?
Workload management and state access use different scopes and APIs. Check the state container's Storage Blob Data Contributor assignment, tenant/client IDs, backend authentication flags, propagation and network access. Contributor on the workload resource group is not sufficient for Blob state access.[5][15]
Why not plan again after the second approval?
That approval is for the changes reviewers just inspected. Apply the saved plan they reviewed. If it is no longer valid, generate a new plan and obtain approval for the updated changes.[11]
Sources
[1] Microsoft — Workload identity federation
[2] Microsoft — Federated credentials for a user-assigned managed identity
[3] AzureRM 5.12.0 — Authentication and registration
[4] GitHub — Deployments and environments
[5] HashiCorp — AzureRM backend
[6] AzureRM 5.12.0 — Service Bus queue
[7] Microsoft — Message expiration
[8] Microsoft — Message locks and settlement
[9] AzureRM 5.12.0 — Service Bus namespace
[10] Microsoft — Resource naming rules
[11] HashiCorp — Terraform plan
[12] HashiCorp — Provider dependency locks
[13] GitHub — OpenID Connect reference
[14] Microsoft — Privileged built-in roles
[15] Microsoft — Storage built-in roles
[16] GitHub — Managing environments
[17] GitHub — Secure use
[18] HashiCorp — Mock providers
Frequently asked questions
What is the purpose of using OIDC with GitHub Actions for Azure?
OIDC eliminates the need for a stored client secret by allowing a GitHub job to authenticate with Azure through a short-lived token, enhancing security by removing long-lived credentials.
How does Terraform manage Azure infrastructure in the described setup?
Terraform uses version-controlled configuration files to describe infrastructure. It manages Azure resources through GitHub Actions, automating changes with consistent processes across environments.
Why are separate Azure subscriptions used for Dev, Stage, and Production?
Separate subscriptions provide isolation for each environment, ensuring that changes are tested and approved independently before reaching production, enhancing control and security.
How does GitHub Actions ensure secure deployments?
GitHub Actions uses environment rules and OIDC authentication to control job execution, requiring approvals before deployments and restricting access to sensitive operations.
What are the benefits of using a managed identity with federated credentials?
A managed identity with federated credentials allows temporary, scoped access to Azure resources without storing long-lived secrets, reducing the risk of credential exposure.
How does the workflow handle failed deployments?
Failed deployments can be retried from the failed job in the same workflow run, maintaining the original commit and ref while ensuring approval gates are respected for sensitive environments.
About the author
- I’m a hands-on technology architect, engineer and founder of Turing Technology Services, with nearly two decades of experience in software engineering, platform engineering, site reliability engineering and cloud infrastructure. My work combines architecture and implementation with technical leadership, coaching and helping organisations adopt new engineering practices. I’ve worked with organisations including Shell, ING, ABN AMRO, Rabobank, Schiphol Group and Ahold Delhaize, across complex and regulated enterprise environments. On this blog, I share practical guides and perspectives on software architecture, cloud platforms, reliability and AI, explaining the reasoning behind technical choices as well as how to implement them.
Comments
Join the discussionNo comments yet. Be the first to comment.
Infrastructure as Code
Designing and managing infrastructure through code. Practical guides to Terraform, reusable modules, state management, testing and drift detection, with maintainable patterns for cloud and platform environments.





