Ship Azure Infrastructure with OIDC
Deploy a Container App with Terraform and GitHub OIDC without cloud secrets.
Introduction
30 Second Summary
A process that depends on memory and a shared password is hard to repeat safely. One missed click can leave the next person guessing.
In this project, you will build a public sample container on Azure Container Apps using Terraform. A GitHub Actions pipeline will use remote state in Azure Blob Storage plus OpenID Connect authentication to deploy changes without an Azure client secret.
What You'll Build
A push to your repository triggers a secretless Terraform deployment that finishes with a public HTTPS page you can open in your browser.
By the end of this project, you'll have:
- A public sample app that responds when you open its HTTPS address in your browser.
- A remote Terraform state that stays in Azure when you run Terraform locally or through automation.
- A secretless deployment pipeline that applies infrastructure changes without storing an Azure password or client secret.
- Secret Mission: Add a pull request check that blocks incorrectly formatted Terraform before deployment.
Are there any prerequisites?
You need a Windows computer, a GitHub account, and an Azure subscription that allows resource creation and role assignments.
The guide covers installation and validation for every local command-line tool.
Before We Start
This checkpoint helps you commit to the delivery pipeline you are about to build. You will connect its public Azure deployment goal to the security benefit of short-lived identity tokens.
Bootstrap the Azure Foundation
Your future CI/CD runner needs a reliable foundation before it can deploy with Terraform. A temporary runner cannot depend on state stored on your computer.
This step creates durable state in Azure Blob Storage. It also creates a user-assigned managed identity with narrowly scoped Azure RBAC permissions.
In this step, get ready to:
- Install Azure CLI 2.90.0, Git 2.55.0, plus Terraform CLI 1.16.3.
- Create the public GitHub repository for your infrastructure code.
- Run a Windows PowerShell script that creates the remote state backend plus the deployment identity.
Install and verify the command-line tools
Three command-line tools drive this project. Azure CLI manages cloud access. Git connects your local repository to GitHub. Terraform defines the infrastructure.
- Press the Windows key to open the Windows search bar.
- Type PowerShell into the search bar.
- Press Enter to open Windows PowerShell.
- Check all three installed versions by running these commands:
az version
git --version
terraform version
What do these checks prove?
- The first command reports the installed Azure CLI version.
- The second command reports the installed Git version.
- The third command reports the installed Terraform CLI version.
Start with the result from az version.
✔️ I see version 2.90.0
Azure CLI is ready to manage the Azure resources in this project.
ⓧ I see an older version
Your Azure CLI needs an update before the bootstrap script can use the expected commands.
- Approve the installer if Windows asks for permission.
- Update Azure CLI by running this command:
winget install --exact --id Microsoft.AzureCLI
What does this command do?
WinGet installs the official Azure CLI package. Running the command against an existing installation updates the package when a newer release is available.
ⓧ Command not found
Windows cannot find Azure CLI yet. Install the official package before continuing.
- Approve the installer if Windows asks for permission.
- Install Azure CLI by running this command:
winget install --exact --id Microsoft.AzureCLI
What does this command do?
WinGet downloads the official Azure CLI package. The installer makes the az command available to new PowerShell sessions.
Next, compare the output from git --version.
✔️ I see version 2.55.0
Git is ready to clone the repository plus track your infrastructure files.
ⓧ I see an older version
Update Git before cloning the repository.
- Approve the installer if Windows asks for permission.
- Update Git by running this command:
winget install --id Git.Git -e --source winget
What does this command do?
WinGet installs the exact Git for Windows package from its configured source. An existing installation is updated when a newer package is available.
ⓧ Command not found
Git is missing from your command path. Install Git for Windows before creating the local project copy.
- Approve the installer if Windows asks for permission.
- Install Git by running this command:
winget install --id Git.Git -e --source winget
What does this command do?
WinGet installs Git for Windows from the named package source. The installation makes the git command available to new PowerShell sessions.
Finish with the output from terraform version.
✔️ I see version 1.16.3
Terraform CLI matches the version required by the configuration you deploy next.
ⓧ I see an older version
This project pins Terraform CLI to 1.16.3. Replace the older executable before continuing.
- Visit the official Terraform installation page.
- Download Terraform 1.16.3 for Windows AMD64.
- Extract the downloaded archive.
- Place the folder containing terraform.exe on your Windows path by following the official installation guidance.
Why use the pinned version?
The Terraform configuration declares 1.16.3 as its required CLI version. Matching that version keeps local runs consistent with the later GitHub Actions workflow.
ⓧ Command not found
Terraform CLI has not been installed in a folder that PowerShell can find.
- Visit the official Terraform installation page.
- Download Terraform 1.16.3 for Windows AMD64.
- Extract the downloaded archive.
- Place the folder containing terraform.exe on your Windows path by following the official installation guidance.
Why does the path matter?
PowerShell searches the folders on your Windows path when you enter a command. Adding the Terraform folder lets you run terraform from the project repository.
- Close Windows PowerShell if you installed or updated any tool.
- Press the Windows key to open the Windows search bar.
- Type PowerShell into the search bar.
- Press Enter to open a fresh Windows PowerShell session.
- Verify the final tool versions by running the checks again:
az version
git --version
terraform version
What should the versions show?
- Azure CLI should report 2.90.0.
- Git should report 2.55.0.
- Terraform CLI should report 1.16.3.
Your local toolchain is ready. Each later deployment now uses the same pinned commands.
Still seeing the wrong version?
- Close every PowerShell window before opening a fresh session.
- Check that the folder containing the required executable appears on your Windows path.
- Restart Windows if an installer completed successfully but the old command still resolves.
Help me diagnose why PowerShell still finds the wrong Azure CLI, Git, or Terraform version.
Create the repository and select Azure access
The repository becomes the shared source for your Terraform configuration. The Azure CLI session determines which subscription receives every resource created by the bootstrap script.
- Open the new repository page in your browser.
- Select your GitHub account as the repository owner.
- Enter azure-oidc-terraform in the Repository name field.
- Select Public as the repository visibility.
- Leave Add README turned off.
- Leave the Git ignore file option empty.
- Leave the license option empty.
- Click Create repository.
The empty repository is now ready for a local copy on your Windows Desktop.
- Return to the PowerShell window from earlier.
- Clone the repository into your Desktop by running these commands:
cd ~/Desktop
git clone https://github.com/[[GITHUB_USERNAME="your GitHub username"]]/azure-oidc-terraform.git
cd azure-oidc-terraform
What do these commands do?
- The first command moves PowerShell to your Desktop.
- The second command creates a local copy of the public repository.
- The third command moves PowerShell into the new azure-oidc-terraform folder.
- Confirm the local repository exists by running:
ls
What should you see?
PowerShell lists the contents of the cloned repository. The list can be empty because you created the repository without starter files.
Could not clone the repository?
- Check that your GitHub username matches the owner shown on the repository page.
- Confirm the repository name is azure-oidc-terraform.
- Confirm the repository visibility is public.
Help me troubleshoot cloning my public GitHub repository with Git on Windows.
Azure CLI uses your selected subscription as the default target. Confirming it now prevents the bootstrap script from creating resources in the wrong subscription.
- Start the interactive Azure sign-in by running:
az login
What happens during sign-in?
Azure CLI opens an interactive authentication flow. If your account can access multiple subscriptions, the flow asks you to select one.
- Complete the sign-in flow in your browser.
- Select the subscription where you can create resources plus role assignments.
- Return to PowerShell after authentication completes.
- Display the active subscription by running:
az account show
Why check the active subscription?
Azure CLI sends later commands to the active subscription. This output lets you confirm its name plus its subscription ID before provisioning begins.
- Confirm the output names the subscription you intend to use.
- Record the active subscription ID as your Azure subscription ID.
- Record the signed-in account email as your Azure sign-in email.
If the output names a different subscription, switch the active subscription before proceeding.
- Select the intended subscription by running this command when needed:
az account set --subscription "[[AZURE_SUBSCRIPTION="your intended Azure subscription name or ID"]]"
What does this command change?
The command changes the active Azure CLI subscription. Later commands use that subscription unless they provide another one explicitly.
- Verify the selected subscription by running the account check again:
az account show
What should you confirm?
The account output should now show the intended subscription. It should also show the Azure account that supplies your Azure sign-in email.
Cannot use the intended subscription?
Your account needs permission to create Azure resources. It also needs Microsoft.Authorization/roleAssignments/write at the scopes used by this project.
Help me check whether my Azure account can create resources and assign roles in the selected subscription.
Provision the Azure foundation
The bootstrap script creates the state resource group plus the application resource group. It also creates the storage account plus the deployment identity.
Why separate the resource groups?
The state backend must survive when you destroy the Terraform-managed application resources. Keeping state in rg-nw-terraform-state lets you remove rg-nw-container-app without deleting the state file first.
- Press the Windows key to open the Windows search bar.
- Type Notepad into the search bar.
- Press Enter to open Notepad.
- Click File in the top menu.
- Select Save As.
- Choose the azure-oidc-terraform folder on your Desktop.
- Enter bootstrap.ps1 as the file name.
- Save the new file.
The first section accepts the two user-specific inputs. It also records the fixed Azure resource names used throughout the project.
- Paste the first script section into the empty bootstrap.ps1 file:
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[a-z0-9]{3,24}$')]
[string]$StateStorageName,
[Parameter(Mandatory = $true)]
[string]$AzureUserEmail
)
$ErrorActionPreference = 'Stop'
$Location = 'australiaeast'
$BootstrapResourceGroup = 'rg-nw-terraform-state'
$AppResourceGroup = 'rg-nw-container-app'
$IdentityName = 'id-nw-github-terraform'
$StateContainerName = 'tfstate'
$StateKey = 'azure-container-app.tfstate'
az account show --only-show-errors | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Run az login before running this script.'
}
$SubscriptionId = (az account show --query id --output tsv).Trim()
$TenantId = (az account show --query tenantId --output tsv).Trim()
What does this section do?
- The parameter block requires a storage account name plus your Azure sign-in email.
- The validation pattern allows only lowercase letters plus numbers in a name between 3 and 24 characters.
- The constants keep every Azure resource name consistent.
- The account check stops the script when Azure CLI has no active sign-in.
- The final variables capture the active subscription ID plus tenant ID.
- Save bootstrap.ps1.
- Confirm the final visible line captures $TenantId.
Does the first section look different?
- Check that the file name ends with .ps1.
- Check that every opening parenthesis has a matching closing parenthesis.
- Check that the storage name validation pattern matches the code block exactly.
Help me compare the parameter and configuration section of bootstrap.ps1 with the expected code.
The next section registers the Azure providers. It then creates the two resource groups plus the state storage account.
- Place the cursor after the last line in bootstrap.ps1.
- Append this resource provisioning section with one blank line above it:
az provider register --namespace Microsoft.App --wait
az provider register --namespace Microsoft.ManagedIdentity --wait
az group create `
--name $BootstrapResourceGroup `
--location $Location `
--only-show-errors | Out-Null
az group create `
--name $AppResourceGroup `
--location $Location `
--only-show-errors | Out-Null
az storage account create `
--name $StateStorageName `
--resource-group $BootstrapResourceGroup `
--location $Location `
--sku Standard_LRS `
--kind StorageV2 `
--min-tls-version TLS1_2 `
--allow-blob-public-access false `
--only-show-errors | Out-Null
What does this section create?
- The provider commands enable Azure Container Apps plus managed identity resources in the selected subscription.
- The resource group commands create rg-nw-terraform-state plus rg-nw-container-app in australiaeast.
- The storage command creates a Standard_LRS StorageV2 account with a TLS 1.2 minimum.
- Public blob access is disabled on the account.
- Save bootstrap.ps1.
- Confirm the final visible line suppresses normal storage command output.
Do the continuation lines look broken?
Every PowerShell continuation character is a backtick at the end of its line. A missing backtick makes the following parameter run as a separate command.
Help me check the provider, resource group, and storage commands in bootstrap.ps1.
The deployment identity needs two identifiers. Its client ID is used for authentication. Its principal ID is used for Azure role assignments.
- Append the managed identity section to bootstrap.ps1 with one blank line above it:
az identity create `
--name $IdentityName `
--resource-group $BootstrapResourceGroup `
--location $Location `
--only-show-errors | Out-Null
$ClientId = (az identity show `
--name $IdentityName `
--resource-group $BootstrapResourceGroup `
--query clientId `
--output tsv).Trim()
$PrincipalId = (az identity show `
--name $IdentityName `
--resource-group $BootstrapResourceGroup `
--query principalId `
--output tsv).Trim()
$StorageScope = "/subscriptions/$SubscriptionId/resourceGroups/$BootstrapResourceGroup/providers/Microsoft.Storage/storageAccounts/$StateStorageName"
$AppScope = "/subscriptions/$SubscriptionId/resourceGroups/$AppResourceGroup"
What does this identity section do?
- The first command creates id-nw-github-terraform in the state resource group.
- The identity queries capture its client ID plus principal ID.
- The scope variables build the exact Azure paths used by the role assignments.
- Save bootstrap.ps1.
- Confirm the final visible line defines $AppScope.
Are the identity variables incomplete?
- Check that both identity queries use $IdentityName.
- Check that clientId uses the same letter casing as the code block.
- Check that principalId uses the same letter casing as the code block.
Help me debug the identity and scope section of bootstrap.ps1.
Least privilege limits each identity to the work it needs. The managed identity can deploy inside the application resource group. It can also read plus update the dedicated state account.
- Append the role assignment section to bootstrap.ps1 with one blank line above it:
az role assignment create `
--assignee $AzureUserEmail `
--role 'Storage Blob Data Contributor' `
--scope $StorageScope `
--only-show-errors | Out-Null
az role assignment create `
--assignee-object-id $PrincipalId `
--assignee-principal-type ServicePrincipal `
--role 'Storage Blob Data Contributor' `
--scope $StorageScope `
--only-show-errors | Out-Null
az role assignment create `
--assignee-object-id $PrincipalId `
--assignee-principal-type ServicePrincipal `
--role Contributor `
--scope $AppScope `
--only-show-errors | Out-Null
How are the permissions scoped?
- Your signed-in Azure user receives Storage Blob Data Contributor on the state storage account.
- The managed identity receives Storage Blob Data Contributor on the same state account.
- The managed identity receives Contributor only on rg-nw-container-app.
- The identity receives no subscription-wide Contributor assignment.
- Save bootstrap.ps1.
- Confirm the final assignment targets $AppScope.
Do the role assignments differ?
- Check that your Azure user assignment uses $AzureUserEmail.
- Check that both managed identity assignments use $PrincipalId.
- Check that the identity principal type is ServicePrincipal.
Help me verify the least-privilege role assignments in bootstrap.ps1.
Azure role assignments can take up to 10 minutes to propagate. The final section retries private container creation while Azure finishes applying your blob data access.
- Append the state container plus output section to bootstrap.ps1 with one blank line above it:
$ContainerCreated = $false
for ($Attempt = 1; $Attempt -le 20; $Attempt++) {
az storage container create `
--name $StateContainerName `
--account-name $StateStorageName `
--auth-mode login `
--public-access off `
--only-show-errors | Out-Null
if ($LASTEXITCODE -eq 0) {
$ContainerCreated = $true
break
}
Start-Sleep -Seconds 30
}
if (-not $ContainerCreated) {
throw 'Azure RBAC did not propagate in time. Wait a few minutes and run the script again.'
}
Write-Host "AZURE_CLIENT_ID=$ClientId"
Write-Host "AZURE_TENANT_ID=$TenantId"
Write-Host "AZURE_SUBSCRIPTION_ID=$SubscriptionId"
Write-Host "TFSTATE_STORAGE_ACCOUNT=$StateStorageName"
Write-Host "TFSTATE_CONTAINER=$StateContainerName"
Write-Host "TFSTATE_KEY=$StateKey"
What does the final section do?
- The loop tries to create the private tfstate container up to 20 times.
- Each failed attempt waits 30 seconds before retrying.
- The error block stops the script if Azure RBAC has not propagated after the retry window.
- The output lines print the identity plus backend values needed by later steps.
- Save bootstrap.ps1.
- Confirm the final visible line prints TFSTATE_KEY.
Does the retry loop look incomplete?
- Check that every opening brace has a matching closing brace.
- Check that the container command uses login authentication.
- Check that all six output lines remain inside the file.
Help me inspect the retry loop and output section of bootstrap.ps1.
Use the tabs below to compare your completed script with the target file.
✔️ Awesome, I've got everything!
Great. Double-check that bootstrap.ps1 is saved inside your cloned azure-oidc-terraform folder.
ⓧ I'd like to double check the full code
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[a-z0-9]{3,24}$')]
[string]$StateStorageName,
[Parameter(Mandatory = $true)]
[string]$AzureUserEmail
)
$ErrorActionPreference = 'Stop'
$Location = 'australiaeast'
$BootstrapResourceGroup = 'rg-nw-terraform-state'
$AppResourceGroup = 'rg-nw-container-app'
$IdentityName = 'id-nw-github-terraform'
$StateContainerName = 'tfstate'
$StateKey = 'azure-container-app.tfstate'
az account show --only-show-errors | Out-Null
if ($LASTEXITCODE -ne 0) {
throw 'Run az login before running this script.'
}
$SubscriptionId = (az account show --query id --output tsv).Trim()
$TenantId = (az account show --query tenantId --output tsv).Trim()
az provider register --namespace Microsoft.App --wait
az provider register --namespace Microsoft.ManagedIdentity --wait
az group create `
--name $BootstrapResourceGroup `
--location $Location `
--only-show-errors | Out-Null
az group create `
--name $AppResourceGroup `
--location $Location `
--only-show-errors | Out-Null
az storage account create `
--name $StateStorageName `
--resource-group $BootstrapResourceGroup `
--location $Location `
--sku Standard_LRS `
--kind StorageV2 `
--min-tls-version TLS1_2 `
--allow-blob-public-access false `
--only-show-errors | Out-Null
az identity create `
--name $IdentityName `
--resource-group $BootstrapResourceGroup `
--location $Location `
--only-show-errors | Out-Null
$ClientId = (az identity show `
--name $IdentityName `
--resource-group $BootstrapResourceGroup `
--query clientId `
--output tsv).Trim()
$PrincipalId = (az identity show `
--name $IdentityName `
--resource-group $BootstrapResourceGroup `
--query principalId `
--output tsv).Trim()
$StorageScope = "/subscriptions/$SubscriptionId/resourceGroups/$BootstrapResourceGroup/providers/Microsoft.Storage/storageAccounts/$StateStorageName"
$AppScope = "/subscriptions/$SubscriptionId/resourceGroups/$AppResourceGroup"
az role assignment create `
--assignee $AzureUserEmail `
--role 'Storage Blob Data Contributor' `
--scope $StorageScope `
--only-show-errors | Out-Null
az role assignment create `
--assignee-object-id $PrincipalId `
--assignee-principal-type ServicePrincipal `
--role 'Storage Blob Data Contributor' `
--scope $StorageScope `
--only-show-errors | Out-Null
az role assignment create `
--assignee-object-id $PrincipalId `
--assignee-principal-type ServicePrincipal `
--role Contributor `
--scope $AppScope `
--only-show-errors | Out-Null
$ContainerCreated = $false
for ($Attempt = 1; $Attempt -le 20; $Attempt++) {
az storage container create `
--name $StateContainerName `
--account-name $StateStorageName `
--auth-mode login `
--public-access off `
--only-show-errors | Out-Null
if ($LASTEXITCODE -eq 0) {
$ContainerCreated = $true
break
}
Start-Sleep -Seconds 30
}
if (-not $ContainerCreated) {
throw 'Azure RBAC did not propagate in time. Wait a few minutes and run the script again.'
}
Write-Host "AZURE_CLIENT_ID=$ClientId"
Write-Host "AZURE_TENANT_ID=$TenantId"
Write-Host "AZURE_SUBSCRIPTION_ID=$SubscriptionId"
Write-Host "TFSTATE_STORAGE_ACCOUNT=$StateStorageName"
Write-Host "TFSTATE_CONTAINER=$StateContainerName"
Write-Host "TFSTATE_KEY=$StateKey"
This next action creates Azure resources that can incur storage charges. The cleanup section later removes both resource groups when you no longer need them.
- Choose a globally unique storage account name using only lowercase letters plus numbers: your globally unique lowercase storage account name.
- Keep the name between 3 and 24 characters.
- Return to the PowerShell window inside the azure-oidc-terraform folder.
- Run the bootstrap script with your storage name plus Azure sign-in email:
.\bootstrap.ps1 -StateStorageName '[[STATE_STORAGE_NAME="your globally unique lowercase storage account name"]]' -AzureUserEmail '[[AZURE_USER_EMAIL="your Azure sign-in email"]]'
What happens during the bootstrap?
The script registers providers before creating both resource groups. It then creates the storage account plus the managed identity.
The script assigns the scoped roles before creating the private state container. It finishes by printing the values required for local Terraform plus GitHub Actions.
This run can take up to 10 minutes while Azure RBAC propagates. The retry loop keeps checking the state container during that wait.
- Keep PowerShell open until the six bootstrap output lines appear.
- Record AZURE_CLIENT_ID as your managed identity client ID.
- Record AZURE_TENANT_ID as your Azure tenant ID.
- Confirm AZURE_SUBSCRIPTION_ID matches your Azure subscription ID.
- Confirm TFSTATE_STORAGE_ACCOUNT matches your globally unique lowercase storage account name.
- Record the fixed output TFSTATE_CONTAINER=tfstate.
- Record the fixed output TFSTATE_KEY=azure-container-app.tfstate.
Did the bootstrap script stop?
- Confirm the storage account name contains only lowercase letters plus numbers.
- Choose another storage account name if Azure reports that the current name is unavailable.
- Use a subscription where your account can create role assignments if a permission error appears.
- Wait a few minutes before rerunning the script if Azure RBAC does not propagate during the retry window.
Help me diagnose why bootstrap.ps1 failed while creating the Azure state backend or role assignments.
Before you run the final check, do you expect Azure CLI to find the managed identity in the state resource group?
- Verify the managed identity by running:
az identity show --name id-nw-github-terraform --resource-group rg-nw-terraform-state --output table
What does this verification prove?
The command looks up id-nw-github-terraform inside rg-nw-terraform-state. A returned table proves the deployment identity now exists in the selected subscription.
That is the Azure foundation in place. Your remote state account plus narrowly scoped deployment identity are ready for Terraform.
Your state backend plus deployment identity are ready. Next, you will define the Azure Container App in Terraform and deploy it locally.
Deploy the First Container App Locally
Your Azure foundation is ready. The next proof point is a working deployment that uses it.
Running locally first isolates the Terraform configuration from future CI/CD authentication. A successful run proves that each infrastructure layer works before automation enters the path.
You will connect Terraform to remote state. You will then deploy a public sample through Azure Container Apps.
In this step, get ready to:
- Configure Terraform to use the pinned AzureRM provider and remote state.
- Define the Container Apps environment and public sample app.
- Apply the configuration and load the public HTTPS page.
Configure Terraform and remote state
A remote Terraform state backend gives each Terraform run one durable record of the deployed infrastructure. The AzureRM backend stores that record in Azure Blob Storage with state locking.
- In your code editor, create versions.tf inside the cloned azure-oidc-terraform folder.
- Add the pinned Terraform requirements to versions.tf using this configuration:
terraform {
required_version = "= 1.16.3"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "= 5.5.0"
}
}
}
provider "azurerm" {
features {}
}
What does this configuration do?
- The required_version setting keeps the project on Terraform 1.16.3.
- The required_providers block selects hashicorp/azurerm version 5.5.0.
- The provider block activates the AzureRM features used by the resources in this project.
- Save versions.tf.
- Check the file's formatting by running this command in the PowerShell panel from earlier:
terraform fmt -check
What does this check do?
This command compares every Terraform file with Terraform's canonical formatting. PowerShell returns to the prompt without listing a file when the current configuration is formatted correctly.
Does Terraform list versions.tf?
Run Terraform's formatting command without the check option if the file needs canonical spacing. Save the resulting change before continuing.
Help me fix the formatting in my versions.tf file.
- In your code editor, create backend.tf inside azure-oidc-terraform.
- Define the remote backend in backend.tf using this configuration:
terraform {
backend "azurerm" {}
}
How does the backend work?
The empty azurerm block declares the backend type. The storage account details stay outside the file so the same configuration can receive them during initialization.
- Save backend.tf.
- Configure this PowerShell session to use your existing Azure CLI sign-in by running:
$env:ARM_USE_CLI = "true"
$env:ARM_USE_AZUREAD = "true"
$env:ARM_SUBSCRIPTION_ID = (az account show --query id --output tsv).Trim()
$env:ARM_TENANT_ID = (az account show --query tenantId --output tsv).Trim()
How does local authentication work?
- The ARM_USE_CLI setting tells Terraform to use your authenticated Azure CLI session.
- The ARM_USE_AZUREAD setting enables Microsoft Entra ID authentication for the state data.
- The subscription ID selects the Azure subscription that receives the deployment.
- The tenant ID identifies the Microsoft Entra tenant for authentication.
- Initialize the remote backend with your recorded storage account name by running:
terraform init -input=false `
-backend-config="storage_account_name=[[TFSTATE_STORAGE_ACCOUNT="your recorded storage account name"]]" `
-backend-config="container_name=tfstate" `
-backend-config="key=azure-container-app.tfstate"
What does initialization connect?
- The storage account holds the state backend created during the bootstrap step.
- The tfstate container keeps the state blob private.
- The azure-container-app.tfstate key identifies this project's state file.
You should see confirmation that Terraform initialized the AzureRM backend. Your future plan and apply operations now share the remote state.
Did backend initialization fail?
- Confirm that the storage account name matches the TFSTATE_STORAGE_ACCOUNT value recorded during the bootstrap step.
- Confirm that PowerShell is still signed into the intended Azure subscription.
- Return to the PowerShell session where you set the four authentication environment variables.
Help me troubleshoot Terraform's AzureRM backend initialization.
Define the Container Apps infrastructure
Terraform needs a variable to record how the infrastructure was delivered. The first deployment uses local-bootstrap so the Azure tags identify this local run.
- In your code editor, create variables.tf inside azure-oidc-terraform.
- Define the delivery variable in variables.tf using this configuration:
variable "delivery" {
type = string
description = "How this infrastructure was last applied."
default = "local-bootstrap"
}
Why track the delivery path?
The delivery variable becomes an Azure resource tag. Its current value shows that Terraform applied the infrastructure from your computer.
- Save variables.tf.
- Confirm that the current Terraform files form a valid configuration by running:
terraform validate
What does validation prove?
Validation checks the Terraform language and provider configuration. You should see confirmation that the configuration is valid.
Is the configuration invalid?
Check that every opening brace in versions.tf, backend.tf, and variables.tf has a matching closing brace.
Help me resolve my Terraform validation error.
The application resource group already exists. A data source lets Terraform read that group so every new Container Apps resource inherits its Azure location.
- In your code editor, create main.tf inside azure-oidc-terraform.
- Add the application resource group data source to main.tf using this configuration:
data "azurerm_resource_group" "app" {
name = "rg-nw-container-app"
}
What does the data source provide?
The azurerm_resource_group data source reads rg-nw-container-app from Azure. Later resources use its name and location without recreating the group.
- Add the Container Apps environment below the data source in main.tf using this configuration:
resource "azurerm_container_app_environment" "main" {
name = "cae-nw-oidc"
location = data.azurerm_resource_group.app.location
resource_group_name = data.azurerm_resource_group.app.name
tags = {
project = "azure-oidc-terraform"
}
}
What does the environment provide?
The azurerm_container_app_environment.main resource creates the managed boundary that hosts the sample app. It uses the existing resource group's location and carries the project tag.
- Save main.tf.
- Check the data source and environment resource by running:
terraform validate
What should validation confirm?
Terraform should report that the configuration is valid. This confirms that the environment can reference the existing resource group.
Does validation point to main.tf?
Compare the resource labels and references in main.tf. A misspelled app or main label breaks the link between the blocks.
Help me fix the resource references in main.tf.
The app resource defines one sample container with a small resource allocation. Its replica range allows Azure to reduce the running count to zero when the app is idle.
- Add the base Container App resource below the environment in main.tf using this configuration:
resource "azurerm_container_app" "main" {
name = "ca-nw-oidc-demo"
container_app_environment_id = azurerm_container_app_environment.main.id
resource_group_name = data.azurerm_resource_group.app.name
revision_mode = "Single"
template {
min_replicas = 0
max_replicas = 1
container {
name = "hello-azure"
image = "mcr.microsoft.com/k8se/quickstart:latest"
cpu = 0.25
memory = "0.5Gi"
}
}
}
What does the app template control?
- The app joins the cae-nw-oidc environment through its resource ID.
- The Single revision mode keeps one active revision.
- The replica settings allow zero idle replicas and one active replica.
- The container runs Microsoft's public sample image with the configured CPU and memory allocation.
- Save main.tf.
- Validate the base app resource by running:
terraform validate
What does this validation cover?
Terraform checks the Container App resource against the pinned AzureRM provider schema. You should see confirmation that the configuration is valid.
Does validation reject the app resource?
Check the nesting of the template block and its container block. Each block must close before the resource's final brace.
Help me fix the Container App resource structure.
External ingress gives the sample a public HTTPS endpoint. Traffic enters through port 80 inside the container.
- In main.tf, find the line revision_mode = "Single".
- Insert this ingress block below that line:
ingress {
external_enabled = true
target_port = 80
traffic_weight {
latest_revision = true
percentage = 100
}
}
How does ingress route traffic?
External ingress exposes the app through an Azure-managed HTTPS address. The traffic rule sends every request to the latest revision.
- Place this tags block after the template block in main.tf.
tags = {
project = "azure-oidc-terraform"
delivery = var.delivery
}
What do these tags record?
The project tag groups this resource with the delivery foundation. The delivery tag records the value supplied by var.delivery.
- Save main.tf.
- Check the completed resource formatting by running:
terraform fmt -check
What should the formatting check show?
PowerShell should return to the prompt without listing main.tf. That confirms the file already follows Terraform's canonical layout.
Does main.tf need formatting?
Run Terraform's formatting command without the check option. Review the indentation that Terraform applies around the new nested blocks.
Help me correct the formatting of my Container App configuration.
An output exposes the deployed hostname after apply. The HTTPS prefix turns that hostname into the address you can load in a browser.
- In your code editor, create outputs.tf inside azure-oidc-terraform.
- Define the public app URL in outputs.tf using this configuration:
output "app_url" {
description = "Public URL for the deployed sample container."
value = "https://${azurerm_container_app.main.latest_revision_fqdn}"
}
How is the URL assembled?
The latest_revision_fqdn value comes from the deployed Container App. Terraform prefixes it with HTTPS and prints it as app_url.
- In your code editor, create .gitignore inside azure-oidc-terraform.
- Add Terraform's generated local files to .gitignore using these patterns:
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
Why ignore these files?
Terraform recreates the provider cache and saved plans when needed. Ignoring local state files also prevents an accidental local copy from entering Git history.
- Save outputs.tf.
- Save .gitignore.
- Validate the complete infrastructure configuration by running:
terraform validate
What should Terraform confirm?
Terraform should report that the configuration is valid. The backend, provider, variable, resources, and output now form one consistent configuration.
Does the final validation fail?
- Read the filename and line number in Terraform's diagnostic.
- Compare the named block with the complete file references below.
Help me diagnose my final Terraform validation error.
✔️ Awesome, I've got everything!
Your Terraform configuration is ready for a deployment plan.
- Save every project file before continuing.
ⓧ I'd like to double check the full code
- Compare each file with the complete references below.
terraform {
required_version = "= 1.16.3"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "= 5.5.0"
}
}
}
provider "azurerm" {
features {}
}
What should versions.tf contain?
This complete file pins Terraform and the AzureRM provider to the project versions.
terraform {
backend "azurerm" {}
}
What should backend.tf contain?
This complete file declares the AzureRM remote backend.
variable "delivery" {
type = string
description = "How this infrastructure was last applied."
default = "local-bootstrap"
}
What should variables.tf contain?
This complete file sets the first deployment's delivery tag to local-bootstrap.
data "azurerm_resource_group" "app" {
name = "rg-nw-container-app"
}
resource "azurerm_container_app_environment" "main" {
name = "cae-nw-oidc"
location = data.azurerm_resource_group.app.location
resource_group_name = data.azurerm_resource_group.app.name
tags = {
project = "azure-oidc-terraform"
}
}
resource "azurerm_container_app" "main" {
name = "ca-nw-oidc-demo"
container_app_environment_id = azurerm_container_app_environment.main.id
resource_group_name = data.azurerm_resource_group.app.name
revision_mode = "Single"
ingress {
external_enabled = true
target_port = 80
traffic_weight {
latest_revision = true
percentage = 100
}
}
template {
min_replicas = 0
max_replicas = 1
container {
name = "hello-azure"
image = "mcr.microsoft.com/k8se/quickstart:latest"
cpu = 0.25
memory = "0.5Gi"
}
}
tags = {
project = "azure-oidc-terraform"
delivery = var.delivery
}
}
What should main.tf contain?
This complete file reads the existing resource group. It defines the Container Apps environment and the public sample app.
output "app_url" {
description = "Public URL for the deployed sample container."
value = "https://${azurerm_container_app.main.latest_revision_fqdn}"
}
What should outputs.tf contain?
This complete file prints the public HTTPS URL after deployment.
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
crash.log
What should .gitignore contain?
This complete file excludes Terraform's generated cache, state, plan, and crash files from Git.
Plan, apply, and verify the deployment
A saved Terraform plan gives you a reviewable snapshot of the proposed changes. Applying that exact file keeps the reviewed actions aligned with the deployment.
- Run the final formatting check from the azure-oidc-terraform folder:
terraform fmt -check
What should the final format check show?
PowerShell should return to the prompt without listing any Terraform files. That confirms every configuration file uses canonical formatting.
- Run the final configuration validation:
terraform validate
What should the final validation show?
Terraform should confirm that the configuration is valid. This check happens before any Azure resource changes.
- Create a saved deployment plan by running:
terraform plan -out main.tfplan
What does the plan contain?
The plan reads the existing resource group from Azure. It proposes the Container Apps environment and app without deleting existing infrastructure.
You should see a plan that creates the Container Apps environment and sample app. Terraform also saves the reviewed actions in main.tfplan.
Did planning fail?
- Confirm that the current PowerShell session still contains the four ARM environment variables.
- Confirm that Azure CLI still points to the intended subscription.
- Run validation again if Terraform reports a configuration diagnostic.
Help me troubleshoot my Terraform plan.
This creates Azure resources
Applying the plan creates resources that can incur Azure usage charges. The cleanup section gives you options to pause or delete them after the project.
Azure may need several minutes to provision the environment and app. Progress messages can pause while the platform completes that work.
Before you apply the plan, what resources do you expect Terraform to create?
- Apply the reviewed plan by running:
terraform apply main.tfplan
What does the apply command do?
Terraform applies only the changes stored in main.tfplan. It then records the resulting infrastructure in the Azure Blob state file.
The apply finishes with a success summary. You should also see the app_url output containing an HTTPS address.
- Copy the HTTPS address printed for app_url.
- Paste the address into your browser's address bar.
- Load the public Container App page.
You should see Microsoft's sample container page over HTTPS. That visible response proves the Terraform configuration deployed a reachable app.
What did this deployment prove?
- External ingress accepts public traffic for the app.
- The container receives requests on port 80.
- The minimum replica count of 0 allows the app to scale to zero while idle.
- The maximum replica count of 1 limits this sample deployment to one replica.
Does the app URL fail to load?
- Wait briefly for the first replica to become ready after a scale from zero.
- Confirm that you copied the complete HTTPS value printed for app_url.
- Check the apply output for a resource creation error before retrying the address.
Help me troubleshoot my deployed Azure Container App URL.
Your public app now runs from Terraform with its state stored remotely. Next, you will move the deployment into GitHub Actions and test the identity trust boundary.
Inspect the OIDC Trust Boundary
Your local Terraform deployment proved that the public Azure Container App works. It also proved that Azure Blob Storage can hold the remote state.
Now GitHub Actions needs to request a short-lived OpenID Connect token. Azure checks the token's subject before it grants access.
In this step, get ready to:
- Add the Terraform deployment workflow to your repository.
- Configure the four repository secrets required by the workflow.
- Run the workflow to capture Azure's trust rejection and the exact OIDC subject claim.
Define the workflow's identity context
A workflow file controls when the deployment runs. It also defines the minimum permissions and identity values available to the runner.
- Return to the editor where you created the existing Terraform files.
- Create .github/workflows/terraform-deploy.yml inside azure-oidc-terraform by using your editor's new-file control.
- Add the workflow trigger and identity context by copying this first section into the file:
name: Terraform Deploy
on:
push:
branches:
- main
workflow_dispatch:
permissions:
id-token: write
contents: read
env:
ARM_USE_OIDC: "true"
ARM_USE_AZUREAD: "true"
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
How does the identity context work?
- The on block starts the workflow for pushes to main.
- The workflow_dispatch trigger also allows a manual run.
- The id-token: write permission allows the workflow to request an OIDC token.
- The contents: read permission gives the checkout action read access to the repository.
- The ARM_USE_OIDC value selects OIDC authentication for Terraform.
- The remaining environment variables identify the managed identity and Azure subscription.
- Save .github/workflows/terraform-deploy.yml.
- Confirm that your editor shows the file under .github/workflows with actions/checkout@v7.0.1 as its final line.
Can't find the workflow file?
- Check that terraform-deploy.yml sits inside .github/workflows within the azure-oidc-terraform repository.
- Check that the filename ends with .yml.
Help me check my GitHub Actions workflow path.
- Append the Terraform installation and deployment steps directly below the checkout step by copying this section:
- name: Install Terraform
uses: hashicorp/setup-terraform@v4.0.1
with:
terraform_version: "1.16.3"
- name: Log in to Azure with OIDC
uses: azure/login@v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Check Terraform formatting
run: terraform fmt -check
- name: Initialize remote state
run: >-
terraform init -input=false
-backend-config="storage_account_name=${{ secrets.TFSTATE_STORAGE_ACCOUNT }}"
-backend-config="container_name=tfstate"
-backend-config="key=azure-container-app.tfstate"
- name: Validate Terraform
run: terraform validate -no-color
- name: Create Terraform plan
run: terraform plan -no-color -input=false -out=tfplan
- name: Apply Terraform plan
run: terraform apply tfplan
What does the deployment job do?
- The setup action installs Terraform 1.16.3 on the runner.
- The Azure Login action presents the requested OIDC token with the managed identity identifiers.
- The formatting and validation steps check the Terraform configuration.
- The initialization step reconnects Terraform to the existing azure-container-app.tfstate blob.
- The final steps create a saved plan and apply that exact plan.
- Save .github/workflows/terraform-deploy.yml.
- Confirm that the final line reads run: terraform apply tfplan.
Does the YAML indentation look uneven?
- Keep every workflow step aligned beneath steps.
- Keep each uses or run entry aligned beneath its matching step name.
Help me compare my workflow indentation with the complete file.
✔️ Awesome, I've got everything!
Great. Double-check that you saved .github/workflows/terraform-deploy.yml.
ⓧ I'd like to double check the full code
name: Terraform Deploy
on:
push:
branches:
- main
workflow_dispatch:
permissions:
id-token: write
contents: read
env:
ARM_USE_OIDC: "true"
ARM_USE_AZUREAD: "true"
ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v7.0.1
- name: Install Terraform
uses: hashicorp/setup-terraform@v4.0.1
with:
terraform_version: "1.16.3"
- name: Log in to Azure with OIDC
uses: azure/login@v3.1.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Check Terraform formatting
run: terraform fmt -check
- name: Initialize remote state
run: >-
terraform init -input=false
-backend-config="storage_account_name=${{ secrets.TFSTATE_STORAGE_ACCOUNT }}"
-backend-config="container_name=tfstate"
-backend-config="key=azure-container-app.tfstate"
- name: Validate Terraform
run: terraform validate -no-color
- name: Create Terraform plan
run: terraform plan -no-color -input=false -out=tfplan
- name: Apply Terraform plan
run: terraform apply tfplan
Store the Azure identifiers in GitHub
The workflow references four repository secrets whose values came from bootstrap.ps1. This setup uses identifiers only.
OIDC supplies the short-lived authentication token at runtime. No long-lived Azure client secret is created.
- Return to the public azure-oidc-terraform repository in your browser.
- Click Settings under the repository name.
- Select Secrets and variables in the Security section of the sidebar.
- Click Actions.
- Click the Secrets tab.
You are now on the repository secrets page. Each saved value stays outside the tracked workflow file.
- Click New repository secret.
- Enter AZURE_CLIENT_ID in the Name field.
- Paste the matching value printed by bootstrap.ps1 into the Secret field.
- Click Add secret.
You should see AZURE_CLIENT_ID in the repository secrets list. GitHub no longer displays its stored value.
- Repeat the same process to create AZURE_TENANT_ID with the matching value printed by bootstrap.ps1.
- Repeat the same process to create AZURE_SUBSCRIPTION_ID with the matching value printed by bootstrap.ps1.
- Repeat the same process to create TFSTATE_STORAGE_ACCOUNT with the matching storage account name.
- Confirm that the repository secrets list contains all four names.
Why are four identifiers enough?
The client ID selects id-nw-github-terraform. The tenant and subscription IDs place that identity in the correct Azure boundary.
The storage account name points Terraform at the existing remote backend. The workflow obtains temporary authentication through OIDC.
Can't add repository secrets?
- Confirm that your signed-in GitHub account has write access to azure-oidc-terraform.
- Return to Secrets and variables under the repository's Settings page.
- Check each secret name against the workflow before saving it.
Help me troubleshoot my GitHub repository secret configuration.
Trigger the trust failure and capture the subject
The workflow can now request a GitHub OIDC token containing its repository and branch identity. This first run tests whether Azure accepts that subject.
- Stage .github/workflows/terraform-deploy.yml in the Git client you used for this repository.
- Create a commit with the message Add Terraform deployment workflow.
Before you push, predict whether Azure will accept the workflow's token.
- Push the commit to main using the same Git client.
- Return to the azure-oidc-terraform repository in your browser.
- Click Actions under the repository name.
- Select Terraform Deploy in the left sidebar.
- Open the newest failed workflow run.
- Click the deploy job under Jobs.
You'll see the Log in to Azure with OIDC step fail. Its logs show AADSTS700213 or another no-matching-federated-credential error.
That red run is useful. It exposes the exact identity Azure refused to trust.
- Expand the Log in to Azure with OIDC step if its logs are collapsed.
- Locate subject claim in the failed login output.
- Copy the complete value into your exact GitHub OIDC subject claim.
Why does Azure reject the token?
The workflow has permission to request a token. Azure still needs an exact trust record for the token's issuer and subject.
Your new repository uses GitHub's immutable subject format by default. The owner ID and repository ID remain part of the claim even if names change.
Letter casing is significant when Azure matches the subject. Copying the generated claim prevents a guessed trust boundary.
Did the workflow fail somewhere else?
- If no workflow appears, confirm that .github/workflows/terraform-deploy.yml exists on the main branch.
- If the login step reports a missing input, compare all four repository secret names with the workflow.
- If the YAML cannot load, compare its indentation with the full-code tab above.
Help me diagnose why my workflow did not reach the expected OIDC trust error.
You have found the precise edge of Azure's trust boundary. Next, you'll authorize that subject and rerun the same deployment without a stored cloud password.
Complete the Secretless Deployment
The failed GitHub Actions run proved that the workflow can request an OIDC token. Azure rejected it because the user-assigned managed identity does not yet trust the token's exact subject.
This step creates the missing federated identity credential. The workflow can then use short-lived access to update your Terraform configuration through the existing remote state.
In this step, get ready to:
- Create the federated identity credential for the exact GitHub OIDC subject.
- Push the final delivery tag through the secretless deployment workflow.
- Rerun the workflow to prove the deployment is idempotent.
Create the federated credential
A federated identity credential records which external token Azure accepts. Its subject must match the claim from the failed workflow exactly.
- Create create-federation.ps1 inside the azure-oidc-terraform repository by pasting this script into a new file in your code editor:
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Subject
)
$ErrorActionPreference = 'Stop'
$BootstrapResourceGroup = 'rg-nw-terraform-state'
$IdentityName = 'id-nw-github-terraform'
$CredentialName = 'github-main'
az identity federated-credential create `
--name $CredentialName `
--identity-name $IdentityName `
--resource-group $BootstrapResourceGroup `
--issuer 'https://token.actions.githubusercontent.com' `
--subject $Subject `
--audiences 'api://AzureADTokenExchange'
if ($LASTEXITCODE -ne 0) {
throw 'Federated identity credential creation failed.'
}
az identity federated-credential show `
--name $CredentialName `
--identity-name $IdentityName `
--resource-group $BootstrapResourceGroup `
--output table
What does this script do?
- The Subject parameter receives the exact claim copied from the failed workflow.
- The resource variables target id-nw-github-terraform inside rg-nw-terraform-state.
- The create command trusts GitHub's token issuer for the api://AzureADTokenExchange audience.
- The final command displays github-main so you can confirm that Azure saved the trust record.
- Save create-federation.ps1.
- Store the subject claim copied from the failed workflow here: your exact copied subject claim.
- Create the federated credential by running this command in the PowerShell session from earlier:
.\create-federation.ps1 -Subject '[[GITHUB_OIDC_SUBJECT="your exact copied subject claim"]]'
What does this command prove?
The script creates the trust record with the exact subject from GitHub. It then reads the credential back from Azure to confirm that the record exists.
You'll see a table for github-main after Azure creates the credential. The listed subject should match the claim you copied earlier.
Federated credential not created?
- Confirm that PowerShell is still working inside the azure-oidc-terraform repository.
- Compare the subject with the failed workflow log. Preserve every letter plus every identifier.
- Confirm that your signed-in Azure account can manage federated credentials on id-nw-github-terraform.
Help me diagnose why create-federation.ps1 could not create the Azure federated identity credential.
The saved script should now match this checkpoint.
✔️ Awesome, I've got everything!
Great. Your saved script targets the correct identity and verifies the new credential.
ⓧ I'd like to double check the full code
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Subject
)
$ErrorActionPreference = 'Stop'
$BootstrapResourceGroup = 'rg-nw-terraform-state'
$IdentityName = 'id-nw-github-terraform'
$CredentialName = 'github-main'
az identity federated-credential create `
--name $CredentialName `
--identity-name $IdentityName `
--resource-group $BootstrapResourceGroup `
--issuer 'https://token.actions.githubusercontent.com' `
--subject $Subject `
--audiences 'api://AzureADTokenExchange'
if ($LASTEXITCODE -ne 0) {
throw 'Federated identity credential creation failed.'
}
az identity federated-credential show `
--name $CredentialName `
--identity-name $IdentityName `
--resource-group $BootstrapResourceGroup `
--output table
Deploy the OIDC tag change
The delivery tag records which path last applied the infrastructure. Changing it gives the workflow one small update to plan through the remote state.
- In variables.tf, replace default = "local-bootstrap" with default = "github-actions-oidc".
- Save variables.tf.
Your updated variable file should match this checkpoint.
✔️ Awesome, I've got everything!
Your delivery value now records the GitHub Actions OIDC deployment path.
ⓧ I'd like to double check the full code
variable "delivery" {
type = string
description = "How this infrastructure was last applied."
default = "github-actions-oidc"
}
Before you push, do you expect the workflow to pass Azure Login now that the exact subject is trusted?
- Commit both files to main by running these commands in PowerShell:
git add .
git commit -m "Complete OIDC federation"
git push origin main
What do these commands do?
- The first command stages the new script plus the updated Terraform variable.
- The second command records both changes in your local Git history.
- The final command sends the commit to main. That push starts the deployment workflow.
The workflow may take a few minutes while Terraform reads the remote state and applies the tag update. A short wait here is expected.
- Return to the azure-oidc-terraform repository in your browser.
- Open the repository's workflow runs page.
- Select the newest Terraform Deploy run.
You'll see Azure Login succeed this time. The remaining Terraform steps should complete with a green status.
- Open the existing app_url from your local deployment in a browser.
You'll see the Microsoft sample container page load over HTTPS. That's the trust boundary closed: your pipeline now deploys without an Azure client secret.
Workflow still failing at Azure Login?
- Compare the federated credential subject with the latest workflow's subject claim character by character.
- Confirm that the repository secrets still contain the client ID for id-nw-github-terraform plus the matching tenant ID and subscription ID.
- Confirm that the federated credential uses GitHub's issuer plus the Azure token-exchange audience.
Help me debug the failed OIDC login using the workflow log and federated credential settings.
Prove the deployment is idempotent
An idempotent deployment settles into the same result when its configuration has not changed. A second workflow run should read the same remote state without proposing another infrastructure update.
Before you rerun the workflow, do you expect Terraform to change any Azure resources?
- Return to the successful Terraform Deploy run.
- Use the rerun control in the upper-right corner.
- Choose the option that reruns every job.
This run can take a few minutes because the workflow still authenticates and checks the complete Terraform configuration.
- Wait for the repeated workflow run to complete.
- Expand the Create Terraform plan step in the completed run.
You'll see the plan report no pending infrastructure changes. The repeated workflow remains green.
- Open the existing app_url again in your browser.
You'll still see the sample container page over HTTPS. That second green run proves your secretless deployment is repeatable.
Second run proposes a change?
- Confirm that no local Terraform file changed after the first successful push.
- Check that the workflow initialized the same tfstate container plus the same azure-container-app.tfstate key.
- Review the plan output to identify the specific attribute that differs from the saved remote state.
Help me understand why my second Terraform workflow run still proposes changes.
Secret mission
Add a Pull Request Formatting Gate
Your deployment workflow protects infrastructure after code reaches main. Add a pull request gate that rejects misformatted Terraform before merge without requesting Azure access.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the Azure resources available, pause the public workload, or delete the Azure foundation. Ongoing charges can come from active Azure Container Apps usage or Azure Storage.
Cost warning
The monthly Consumption plan grant includes 180,000 vCPU-seconds. It also includes 360,000 GiB-seconds. It covers 2 million requests.
No usage charges apply while an app is scaled to zero. Azure Storage or usage beyond these grants can still incur charges.
Choose Pause to remove the public workload while keeping the deployment foundation. Choose Delete to remove every Azure resource from this project.
Resources you used:
- The application resource group rg-nw-container-app, containing the Terraform-managed Container Apps environment cae-nw-oidc and public Container App ca-nw-oidc-demo.
- The state resource group rg-nw-terraform-state, containing the storage account created by bootstrap.ps1, the private tfstate blob container, and the remote azure-container-app.tfstate state file.
- The user-assigned managed identity id-nw-github-terraform and its federated identity credential github-main.
- The Azure RBAC assignments granting deployment access to the application resource group and state access to the storage account.
Keep everything running
No action is needed. Choose this if you want to keep demonstrating the public application and secretless deployment pipeline.
- Keep rg-nw-container-app to preserve the public Container App and its HTTPS URL.
- Keep rg-nw-terraform-state to preserve the remote state backend and deployment identity.
- Keep the GitHub Actions workflows to continue deploying infrastructure changes through OIDC.
- Check Cost Management in Azure to confirm the charges generated by your subscription.
Pause - I'll come back to this later
Destroy the Terraform-managed Container App resources to stop serving the public workload. The state backend and deployment identity stay available for redeployment.
- Switch back to Windows PowerShell in your azure-oidc-terraform repository from earlier.
- Preview the destroy operation by running this command:
terraform plan -destroy -out main.destroy.tfplan
What does this command do?
Terraform compares the remote state with the current Azure resources. It saves the reviewed destroy plan as main.destroy.tfplan without changing the resources.
Applying the saved plan removes the public workload. Your remote state remains available in Azure Storage.
- Apply the reviewed destroy plan by running this command:
terraform apply main.destroy.tfplan
What does this command do?
Terraform removes ca-nw-oidc-demo and cae-nw-oidc from rg-nw-container-app. It updates the remote state to record that those resources no longer exist.
- Confirm Terraform reports that the planned resources were destroyed.
- Refresh the public app_url from earlier.
You should no longer see the Microsoft sample container page. That is the workload safely paused while your deployment foundation remains ready for another apply.
Delete - I don't want to use this again
Remove all Azure project resources when you are finished. This is the irreversible cloud cleanup, so the order below protects your remote state until Terraform finishes.
- Switch back to Windows PowerShell in your azure-oidc-terraform repository from earlier.
- Destroy the Terraform-managed workload by running this reviewed sequence:
terraform plan -destroy -out main.destroy.tfplan
terraform apply main.destroy.tfplan
Why destroy through Terraform first?
- The first command records the planned removals in main.destroy.tfplan.
- The second command removes the Container App and its environment while the remote state is still accessible.
- Confirm Terraform reports that the planned resources were destroyed.
Azure CLI asks for confirmation before each resource group deletion. Confirm only after checking that the Terraform destroy completed.
- Delete both Azure resource groups by running these commands:
az group delete --name rg-nw-container-app
az group delete --name rg-nw-terraform-state
What leaves Azure?
The first command deletes the application resource group after Terraform has removed its managed resources.
The second command deletes the storage account and remote state. It also deletes id-nw-github-terraform and github-main.
Deleting these scopes also removes the project role assignments attached to them.
The final identity check is expected to fail because id-nw-github-terraform should no longer exist.
- Confirm the deployment identity is gone by running this command:
az identity show --name id-nw-github-terraform --resource-group rg-nw-terraform-state --output table
What should you see?
You should receive a response indicating that Azure cannot find the resource group or identity. That expected failure confirms the deployment identity has been removed.
- Refresh the public Container App URL from earlier.
You should no longer see the sample container page. Your Azure resources are now removed, so they can no longer generate project usage charges.
Nice Work!
Nice Work!
That is the full secretless delivery path working! Your public app on Azure Container Apps now moves through a Terraform pipeline powered by GitHub Actions with short-lived OIDC authentication.
What you learned:
- Deployed a public container through Terraform. You verified its HTTPS endpoint. Azure Blob Storage held the remote Terraform state with locking.
- Scoped Azure RBAC according to least privilege. The user-assigned managed identity can deploy only in the application resource group. Its state access stays limited to the dedicated storage account.
- Diagnosed the deliberate OIDC trust failure from its exact subject claim. You completed workload identity federation for GitHub Actions. The workflow applied the change without an Azure client secret. An unchanged rerun proved idempotence.
- Secret Mission: Built a Terraform Format Check pull request gate. Misformatted Terraform made the check fail. Restoring canonical formatting made the same check pass without Azure permissions.
Ready to quiz yourself?