Purpose of this file
Create the storage account that holds the main project's Terraform state, and a dedicated Azure identity that GitHub Actions uses to run that main project — before the main project itself can exist.
Every file covered earlier in this tutorial describes infrastructure that uses a remote state backend (backend.tf) and an OIDC identity (github_oidc.tf). Something has to create that storage account and that first identity in the first place — and it can't be the main project's own Terraform run, because that run needs somewhere to store its state before it even starts. Bootstrap breaks that cycle.
Why this is a separate configuration not part of the main project
terraform/bootstrap/ is a completely separate Terraform root module, with its own local terraform.tfstate file (deliberately not stored remotely — there's nowhere remote for it to go yet). It is run manually, once, by someone with broad Azure permissions (like a subscription Owner), and the main application workflow never touches it. Running terraform destroy on the main project in terraform/ removes the application resources, but leaves the bootstrap resources — the state storage and the deployment identity — completely alone.
The identity created here needs subscription-level permissions, because it has to be able to create and destroy the application's entire resource group. That's meaningfully broader than the narrowly-scoped, per-resource permissions granted in github_oidc.tf. Keeping that broader-privilege setup in a separate, manually-run configuration — rather than something any application code change could trigger — is a deliberate safety boundary.
The full file code sample
terraform/bootstrap/main.tf# Run once locally with an Azure administrator account.
# This root module deliberately has separate state from the application.
terraform {
required_version = ">= 1.7, < 2.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.2.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
resource_providers_to_register = [
"Microsoft.Storage", "Microsoft.ManagedIdentity",
"Microsoft.Web", "Microsoft.DBforPostgreSQL",
]
}
variable "subscription_id" {
type = string
}
variable "user_name" {
type = string
default = "dzierzon"
}
variable "location" {
type = string
default = "westus2"
}
variable "github_subject_repository" {
type = string
default = "gdzierzon@9723466/cohort-d-dzierzong-metals-simple-deploy@1361521533"
}
data "azurerm_client_config" "current" {}
locals {
subscription_scope = "/subscriptions/${var.subscription_id}"
}
resource "azurerm_resource_group" "bootstrap" {
name = "expeditors-${var.user_name}-terraform-rg"
location = var.location
}
resource "azurerm_storage_account" "state" {
name = "tfmetals${substr(sha256("${var.subscription_id}/${var.user_name}"), 0, 16)}"
resource_group_name = azurerm_resource_group.bootstrap.name
location = azurerm_resource_group.bootstrap.location
account_tier = "Standard"
account_replication_type = "LRS"
shared_access_key_enabled = false
allow_nested_items_to_be_public = false
min_tls_version = "TLS1_2"
blob_properties {
versioning_enabled = true
delete_retention_policy {
days = 7
}
}
}
resource "azurerm_storage_container" "state" {
name = "tfstate"
storage_account_id = azurerm_storage_account.state.id
container_access_type = "private"
}
resource "azurerm_user_assigned_identity" "terraform" {
name = "expeditors-${var.user_name}-terraform-github"
resource_group_name = azurerm_resource_group.bootstrap.name
location = azurerm_resource_group.bootstrap.location
}
resource "azurerm_federated_identity_credential" "terraform" {
name = "github-terraform"
user_assigned_identity_id = azurerm_user_assigned_identity.terraform.id
issuer = "https://token.actions.githubusercontent.com"
audience = ["api://AzureADTokenExchange"]
subject = "repo:${var.github_subject_repository}:environment:Terraform"
}
# Subscription scope is needed because the application resource group itself
# is created/destroyed by the workflow. Use a dedicated teaching subscription.
resource "azurerm_role_assignment" "infrastructure" {
scope = local.subscription_scope
role_definition_name = "Contributor"
principal_id = azurerm_user_assigned_identity.terraform.principal_id
principal_type = "ServicePrincipal"
}
# Permit assigning/removing ONLY Website Contributor to service principals.
# The workflow cannot use this assignment to grant Owner or Contributor.
resource "azurerm_role_assignment" "deployment_roles" {
scope = local.subscription_scope
role_definition_name = "Role Based Access Control Administrator"
principal_id = azurerm_user_assigned_identity.terraform.principal_id
principal_type = "ServicePrincipal"
condition_version = "2.0"
condition = <<-CONDITION
(
(!(ActionMatches{'Microsoft.Authorization/roleAssignments/write'}))
OR
(
@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {de139f84-1756-47ae-9be6-808fbbe84772, b24988ac-6180-42a0-ab88-20f7382dd24c}
AND @Request[Microsoft.Authorization/roleAssignments:PrincipalType] ForAnyOfAnyValues:StringEqualsIgnoreCase {'ServicePrincipal'}
)
)
AND
(
(!(ActionMatches{'Microsoft.Authorization/roleAssignments/delete'}))
OR
(
@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {de139f84-1756-47ae-9be6-808fbbe84772, b24988ac-6180-42a0-ab88-20f7382dd24c}
AND @Resource[Microsoft.Authorization/roleAssignments:PrincipalType] ForAnyOfAnyValues:StringEqualsIgnoreCase {'ServicePrincipal'}
)
)
CONDITION
}
resource "azurerm_role_assignment" "state_workflow" {
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"
}
# Allows the bootstrap operator to migrate existing state and run Terraform locally.
resource "azurerm_role_assignment" "state_operator" {
scope = azurerm_storage_container.state.id
role_definition_name = "Storage Blob Data Contributor"
principal_id = data.azurerm_client_config.current.object_id
}
output "github_secrets" {
value = {
TF_AZURE_CLIENT_ID = azurerm_user_assigned_identity.terraform.client_id
TF_AZURE_TENANT_ID = azurerm_user_assigned_identity.terraform.tenant_id
TF_AZURE_SUBSCRIPTION_ID = var.subscription_id
}
}
output "github_variables" {
value = {
TF_STATE_STORAGE_ACCOUNT = azurerm_storage_account.state.name
TF_USER_NAME = var.user_name
TF_LOCATION = var.location
}
}
The state storage account where everyone's Terraform memory lives
name = "tfmetals${substr(sha256("${var.subscription_id}/${var.user_name}"), 0, 16)}"
Azure Storage account names must be globally unique across all of Azure, all lowercase letters and digits, and short. This builds a name by hashing the subscription ID and user name together with sha256(), then taking the first 16 characters of that hash with substr(). The result is effectively unique to this subscription and user, and — unlike a random name — it's the same every time this file runs for the same inputs, so re-running bootstrap doesn't create a second, orphaned storage account.
Azure Storage accounts normally have simple access keys that grant full control to anyone holding them — effectively a master password. Disabling them entirely means the only way in is through Microsoft Entra ID identities and explicit role assignments, which is exactly how backend.tf's use_azuread_auth = true is able to work.
Blocks any container inside this storage account from ever being made publicly readable over the internet, even by a future misconfiguration.
versioning_enabled keeps every previous version of the state file, not just the latest one — so a bad Terraform run that corrupts state can be rolled back. delete_retention_policy means even a deleted state file is recoverable for 7 days before it's gone for good. Together they turn a single mistaken overwrite or delete into something recoverable, not catastrophic.
The actual folder-like container inside the storage account where state blobs live, named "tfstate" — matching the container name referenced in backend.tf's configuration. container_access_type = "private" means it can't be browsed or read anonymously.
The Terraform identity a second, separate identity
azurerm_user_assigned_identity.terraform and its matching azurerm_federated_identity_credential.terraform follow the exact same OIDC pattern explained in detail on the github_oidc.tf page — a GitHub Actions identity trusted only for tokens matching a specific subject. The key difference is the subject itself: "repo:${var.github_subject_repository}:environment:Terraform" trusts the Terraform GitHub environment, not the Development environment trusted in the main project. This is a deliberately different, more privileged identity from the one the application deployment workflow uses.
The role assignments what this identity is allowed to do
Contributor allows creating, changing, and deleting most types of Azure resources — but not managing who else has access. It's granted at local.subscription_scope (the whole subscription) rather than a single resource group, because this identity's job is to create and later destroy the application's entire resource group, which can't be scoped any narrower without already assuming that resource group exists.
The main project's github_oidc.tf needs to grant the application's own deployment identity a role — and granting roles to others is itself a permission (Role Based Access Control Administrator), separate from Contributor. Granting that permission without limits would let this identity grant any role, including full ownership of the subscription, to anyone. The condition block attached here is Azure's fine-grained permission system (ABAC) restricting exactly what this broad-sounding role is allowed to do in practice.
In plain terms, the condition says: this identity may only assign or remove two specific roles — de139f84-... (Website Contributor) and b24988ac-... (Contributor) — and only when the recipient is a ServicePrincipal, never a human account. It cannot use this permission to make itself, or anything else, an Owner.
Contributor was added to the allow-list because github_oidc.tf needs to grant it on the container registry for az acr build to work. Be clear-eyed about the trade-off: the condition constrains which role may be granted, not at what scope, and this assignment is held at subscription level — so the Terraform identity could grant Contributor to a service principal anywhere in the subscription. Acceptable for a teaching subscription; a production setup would constrain the scope too.
Changing this condition requires applying the bootstrap module locally with an elevated account. The CI pipeline deliberately cannot edit its own guardrail — which is much of the point of keeping bootstrap in separate state.
Two nearly identical role assignments granting read/write access to the tfstate container specifically: one for the Terraform identity itself (state_workflow, so the GitHub Actions workflow can read and write state), and one for data.azurerm_client_config.current.object_id — the Azure identity of whoever is running bootstrap right now (state_operator), so the same person can also run Terraform locally, for example to migrate existing state per the project's README.md.
The outputs what to hand to GitHub
The Terraform identity's client ID, tenant ID, and the subscription ID — copied into the Terraform GitHub environment as TF_AZURE_* secrets, so the infrastructure workflow can authenticate as this identity.
The state storage account's name, plus the chosen user name and location — copied into the same environment as TF_STATE_STORAGE_ACCOUNT, TF_USER_NAME, and TF_LOCATION, which the main project's workflow needs to configure its backend and match its resource naming.
Key terms for beginners
- Root module
- A self-contained Terraform configuration with its own state. This project has two: the main one in
terraform/, and this separate one interraform/bootstrap/. - Data source
- A block (like
data "azurerm_client_config" "current") that reads existing information instead of creating something new — here, details about whoever is currently authenticated. - ABAC condition
- Attribute-Based Access Control: an extra, fine-grained rule attached to a role assignment that narrows a broad-sounding permission down to only specific, allowed actions.
- Access key vs. Entra ID auth
- Two ways to authenticate to Azure Storage: a shared secret key (simple, but powerful and easy to leak), or a Microsoft Entra ID identity checked against specific role assignments (more setup, much narrower blast radius if compromised).