TutorialsTerraform › bootstrap/main.tf

Terraform Tutorial · File 9 of 9

bootstrap/main.tf

A separate, one-time setup that has to run before everything covered so far. It creates the safe, shared storage location where the main project's state lives, and a dedicated identity that GitHub Actions uses to run the main project's Terraform — solving the chicken-and-egg problem of “where does Terraform's own memory live before Terraform has anywhere to put it?”

Codey the Sr Developer, standing with a pointer, ready to walk through the guide
Run → once, locally, by an administrator State → separate from the main project Folder → terraform/bootstrap/

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.

Codey hugging a rubber duck
solving the chicken-and-egg problem

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

Its own state, its own lifecycleindependence

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.

Broader permissions, on purposea security boundary

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

A unique, predictable nameazurerm_storage_account name
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.

shared_access_key_enabled = falseno master key

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.

allow_nested_items_to_be_public = falseno accidental public access

Blocks any container inside this storage account from ever being made publicly readable over the internet, even by a future misconfiguration.

blob_properties { versioning_enabled = true, delete_retention_policy { days = 7 } }protecting state from mistakes

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.

azurerm_storage_container "state"tfstate container

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

infrastructure: Contributor at subscription scopebroad, but scoped to infrastructure

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.

deployment_roles: a carefully limited RBAC Administratorthe most guarded permission here

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 rolesde139f84-... (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.

state_workflow & state_operator: Storage Blob Data Contributoraccess to the state container

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

github_secretsgo into the Terraform environment's secrets

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.

github_variablesgo into the Terraform environment's variables

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.

These two outputs are exactly the values the main project's backend.tf and GitHub Actions workflow expect to receive — bootstrap's whole job ends here, handing off a working foundation for everything else in this tutorial to build on.

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 in terraform/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).
Codey giving a thumbs up

That's all nine files — you now know exactly what every part of this project's Terraform setup does.