Skip to content

State and Backends

Rod Christiansen edited this page Oct 8, 2026 · 3 revisions

State and Backends

What this module expects of your Terraform state, and what is in it.

The backend is declared but not configured

versions.tf contains:

terraform {
  backend "azurerm" {}
}

An empty block means every backend setting is supplied at init time. There is no local-state fallback and no committed backend file — .gitignore excludes both backend.tf and backend.tfvars.

Copy backend.tf.example to backend.tfvars, fill it in, and pass it to init:

terraform init -backend-config=backend.tfvars

The four keys it expects are resource_group_name, storage_account_name, container_name and key.

Creating the state storage

The state account should live outside the ReportMate resource group so a mistaken destroy of the application resources cannot take the state with it.

az group create --name rg-terraform-state --location canadacentral
az storage account create --name examplestatestore --resource-group rg-terraform-state --sku Standard_LRS
az storage container create --name terraform-state --account-name examplestatestore

Enable blob versioning and soft delete on that account. Nothing in this module does it for you, and the state is the only record of which random-suffixed resources belong to this deployment.

What ends up in state, in clear

Terraform state holds every value a resource reads back, including sensitive ones. For this module that includes at minimum:

  • The PostgreSQL administrator password, and the full connection string built from it.
  • The storage account access key and connection string.
  • The Application Insights instrumentation key and connection string.
  • The generated Entra ID client secret and the generated NextAuth secret.
  • client_passphrases, api_internal_secret, kiosk_tokens, and every Teams webhook URL.

Treat the state container as a secret store. Restrict it with RBAC, do not copy state files around, and do not paste terraform show -json output anywhere.

The Key Vault this module creates does not change that: the vault is populated from Terraform variables, so the values pass through state on their way in. The containers module comments are explicit about this being a known trade-off rather than an oversight.

Guardrails in state

Two resources carry prevent_destroy:

  • module.database.azurerm_postgresql_flexible_server.pg
  • module.database.azurerm_postgresql_flexible_server_database.db

Any plan that would replace or destroy either fails outright. That includes changes to create-time-only attributes — see Upgrading for which ones force replacement.

Conditional module addresses

Three modules use count, so their state addresses are indexed:

Module Address Present when
key_vault module.key_vault[0] enable_key_vault = true
functions module.functions[0] enable_functions = true
networking module.networking[0] enable_custom_domain = true and custom_domain_name != ""

Flipping one of those flags off does not orphan resources — it plans their destruction. Turning enable_key_vault off destroys the vault (subject to purge protection and soft-delete retention) and strips NEXTAUTH_SECRET and AZURE_AD_CLIENT_SECRET from the dashboard container.

The removed block

main.tf ends with:

removed {
  from = module.auth.azurerm_key_vault_secret.nextauth_secret

  lifecycle {
    destroy = false
  }
}

This drops a resource from state without deleting the underlying vault secret, which the key_vault module still manages. It requires Terraform 1.7 or later in practice even though required_version says >= 1.5. Leave it in place until every state file that ever held that resource has been applied at least once; removing it early re-introduces the duplicate writer that rotated the session secret on every apply.

Drift you should expect and ignore

Several attributes are deliberately not owned by Terraform, so a manual change to them will not show up as drift — and reverting them by hand will be undone. The full list is in Architecture; the ones that surprise people most are the container image tags (owned by the deploy pipeline) and the PostgreSQL storage size (owned by auto-grow).

Conversely, the Function App's app_settings are owned outright. A setting added with az functionapp config appsettings set is erased by the next apply. Use function_app_additional_settings instead.

Provisioners that run on every apply

The auth module's null_resources use always_run = timestamp() triggers, so they execute on every apply and shell out to az ad app update. One always exists; the second exists only when enable_oidc_api is true. This means:

  • The apply host needs a working Azure CLI login, not just provider credentials.
  • Plans are never fully clean — those resources always show as changing.

The provisioners use on_failure = continue, so a transient Entra failure does not fail the apply; the next run retries.

Clone this wiki locally