Configurazione del backend remoto Terraform su Azure Storage con autenticazione Microsoft Entra ID
Quando si gestisce l’infrastruttura Azure con Terraform, lo state file rappresenta la fonte di verità che lega il codice alle risorse reali deployate nel cloud. Mantenere lo state in locale è accettabile solo per esperimenti individuali: appena un team o una pipeline CI/CD entra in gioco, lo state deve essere centralizzato, protetto e condiviso.
Il backend azurerm di Terraform permette di salvare gli state file in un container blob di uno Azure Storage Account, ottenendo locking automatico tramite lease del blob, cifratura a riposo e controllo degli accessi tramite Microsoft Entra ID e Azure RBAC, senza utilizzare shared key.
In questa guida vedremo come effettuare il deployment del layer di bootstrap che crea lo storage account del backend utilizzando i moduli Azure Verified Modules (AVM), come risolvere il classico problema “uovo e gallina” migrando lo state da locale a remoto, e come gestire il backend nell’operatività quotidiana. Il codice di riferimento si trova nella directory management/rg-tfstate del repository IaC.
Architettura della soluzione
Il layer crea quattro elementi, volutamente isolati in un resource group dedicato in modo che gli altri layer possano essere distrutti e ricreati senza alcun rischio per gli state file:
- Resource group rg-tfstate – contiene esclusivamente lo storage del backend, creato con il modulo AVM avm-res-resources-resourcegroup v0.2.2.
- Storage account btechtfbackend001 – StorageV2, replica Standard LRS, regione Italy North, creato con il modulo AVM avm-res-storage-storageaccount v0.6.7.
- Container blob tfstate – accesso privato, ospita gli state file di tutti i layer del progetto, ciascuno con una chiave diversa.
- Role assignment Storage Blob Data Contributor – assegnato dinamicamente all’identità che esegue Terraform, letta a runtime con il data source azurerm_client_config, senza object ID hardcodati.
La scelta di sicurezza centrale è shared_access_key_enabled = false: lo storage account non espone chiavi condivise e l’unico canale di accesso ai blob è un’identità Entra ID con ruolo RBAC adeguato. Per lo stesso motivo il provider azurerm è configurato con storage_use_azuread = true e il backend con use_azuread_auth = true.
NOTA: La rete dello storage è volutamente aperta (public_network_access_enabled = true) per consentire lo sviluppo da macchine locali, sempre previa autenticazione Entra ID. In ambienti più protetti è possibile restringere l’accesso agli IP degli agenti CI/CD o adottare un private endpoint.
Analisi del codice Terraform
Vediamo ora nel dettaglio i file che compongono il layer, con il codice completo e la spiegazione di ogni scelta implementativa. La struttura segue la suddivisione classica di un modulo Terraform: provider e versioni, backend, variabili, valori derivati e risorse.
provider.tf — Provider e versioni
Qui dichiariamo i provider necessari con le versioni pinnate, per evitare che un aggiornamento automatico introduca breaking change. Oltre ad azurerm troviamo azapi: non lo usiamo direttamente, ma il modulo AVM dello storage lo richiama internamente, quindi va dichiarato.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
terraform { required_providers { azurerm = { source = "hashicorp/azurerm" version = "~>4.71.0" } azapi = { source = "azure/azapi" version = "~> 2.0" } } required_version = ">= 1.5" } provider "azurerm" { subscription_id = var.azure_subscription_id storage_use_azuread = true features {} } provider "azapi" { subscription_id = var.azure_subscription_id } |
La riga chiave è storage_use_azuread = true nel provider azurerm. Poiche lo storage account ha la shared key disabilitata, senza questa impostazione il provider tenterebbe di autenticarsi con la chiave dell’account e fallirebbe: l’opzione lo forza ad usare l’identita’ Entra ID corrente per tutte le operazioni sui blob.
backend.tf — Configurazione del backend remoto
Il blocco backend è volutamente vuoto. Tutti i parametri (subscription, storage account, container, chiave) vengono forniti al momento dell’init tramite il file state.config, cosi nessun identificativo finisce hardcodato nel codice versionato:
|
1 2 3 4 |
terraform { backend "azurerm" {} } |
L’inizializzazione avviene quindi con terraform init -backend-config=”state.config”, come visto nei passi di deployment.
variables.tf — Variabili di input
Tutte le variabili del layer sono raccolte qui. I default coprono il caso standard (ambiente di produzione in Italy North); per cambiare environment o regione basta sovrascrivere i valori in terraform.tfvars senza toccare il codice.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
variable "location" { description = "Regione Azure dove verranno create le risorse" type = string default = "italynorth" } variable "environment" { description = "Nome dell'ambiente, usato nei tag" type = string default = "prd" } variable "global_tags" { description = "Tag aggiuntivi da aggiungere a tutte le risorse" type = map(string) default = {} } variable "resource_group_name" { description = "Nome del resource group che contiene lo storage account del backend" type = string default = "rg-tfstate" } variable "azure_subscription_id" { description = "ID della subscription Azure dove verranno create le risorse" type = string sensitive = true } |
Da notare la variabile azure_subscription_id marcata come sensitive: cosi il suo valore non compare nell’output di plan e apply. La variabile global_tags è una mappa vuota di default e serve ad aggiungere tag extra a runtime senza modificare il codice.
locals.tf — Costruzione dei tag
I tag applicati a tutte le risorse vengono costruiti con la funzione merge, che unisce un set di tag obbligatori con quelli eventualmente passati via global_tags. In caso di chiavi duplicate, i valori in global_tags hanno la precedenza:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
locals { global_tags = merge( { Environment = var.environment ManagedBy = "Terraform" Project = "Azure-IaC" Scope = "Management" }, var.global_tags ) } |
main.tf — Risorse e role assignment
È il cuore del layer. Per prima cosa il data source azurerm_client_config legge a runtime l’identità che sta eseguendo Terraform (utente in locale, service principal in pipeline), in modo da assegnarle i permessi senza hardcodare alcun object ID:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
data "azurerm_client_config" "current" {} module "resource_group" { source = "Azure/avm-res-resources-resourcegroup/azurerm" version = "0.2.2" name = var.resource_group_name location = var.location tags = local.global_tags enable_telemetry = false } |
Il resource group viene creato dal modulo AVM dedicato e riceve i tag costruiti in locals.tf. Tenerlo isolato dagli altri resource group consente di distruggere e ricreare gli altri layer senza mai mettere a rischio gli state file.
Segue lo storage account, anch’esso da modulo AVM, con la configurazione di sicurezza e il container per lo state:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
module "storage_account" { source = "Azure/avm-res-storage-storageaccount/azurerm" version = "0.6.7" name = "btechtfbackend001" resource_group_name = module.resource_group.name location = var.location account_tier = "Standard" account_replication_type = "LRS" account_kind = "StorageV2" shared_access_key_enabled = false public_network_access_enabled = true containers = { tfstate = { name = "tfstate" container_access_type = "private" } } role_assignments = { current_user = { role_definition_id_or_name = "Storage Blob Data Contributor" principal_id = data.azurerm_client_config.current.object_id skip_service_principal_aad_check = false } } network_rules = { default_action = "Allow" } } |
I punti salienti di questo blocco: shared_access_key_enabled = false disabilita le chiavi condivise (da cui la necessità di storage_use_azuread nel provider); il blocco containers crea il container tfstate con accesso privato; il blocco role_assignments assegna Storage Blob Data Contributor all’object ID letto dal data source, cioè a chi esegue Terraform. Infine network_rules con default_action Allow lascia la rete aperta per lo sviluppo locale.
NOTA: I moduli Azure Verified Modules (AVM) sono moduli ufficiali Microsoft che incapsulano le best practice di sicurezza e configurazione. Usarli al posto delle risorse native azurerm garantisce default solidi (come la disabilitazione della shared key) e riduce il codice da mantenere.
terraform.tfvars — Valori dell’ambiente
Infine, il file con i valori concreti per questo ambiente, che sovrascrive i default delle variabili:
|
1 2 3 |
azure_subscription_id = "d519edb8-c5c7-4a14-994d-25379ed5490b" location = "italynorth" |
ATTENZIONE: Il file terraform.tfvars contiene la subscription ID in chiaro. Anche se la variabile è marcata sensitive lato Terraform, valutare se questo file debba essere versionato nel repository o escluso tramite .gitignore e distribuito per altra via.
Prerequisiti
- Terraform versione 1.5 o superiore.
- Azure CLI installata e autenticata con az login.
- Subscription di destinazione selezionata come subscription corrente della CLI.
- Permessi per creare resource group, storage account e role assignment: in pratica Owner, oppure Contributor + User Access Administrator.
Per selezionare la subscription corretta:
|
1 2 |
az account set --subscription "d519edb8-c5c7-4a14-994d-25379ed5490b" |
Deployment del backend remoto
Questo layer presenta una particolarità: crea lo storage account in cui il suo stesso state dovrà essere salvato, ma al primo run lo storage non esiste ancora. La procedura corretta prevede quindi un primo apply con state locale e una successiva migrazione dello state nel backend remoto appena creato.
Passo 1: Esaminare la configurazione del backend
Il file backend.tf contiene un blocco backend intenzionalmente vuoto. Tutti i parametri vengono passati al momento dell’init tramite il file state.config (pattern di partial backend configuration). In questo modo nessun ID o nome di risorsa è hardcodato nel sorgente e lo stesso codice è riutilizzabile su subscription o tenant diversi.
|
1 2 3 4 |
terraform { backend "azurerm" {} } |
Il contenuto di state.config per questo layer è il seguente:
|
1 2 3 4 5 6 7 8 |
subscription_id = "d519edb8-c5c7-4a14-994d-25379ed5490b" resource_group_name = "rg-tfstate" storage_account_name = "btechtfbackend001" container_name = "tfstate" key = "management/rg-tfstate/terraform.tfstate" use_oidc = false use_azuread_auth = true |
NOTA: Il parametro use_oidc resta false per l’esecuzione locale con la sessione di az login. Nelle pipeline GitHub Actions o Azure DevOps con federated credentials va impostato a true, tipicamente in un file di configurazione dedicato alla pipeline.
Passo 2: Inizializzare Terraform con state locale
Posizionarsi nella directory management/rg-tfstate ed eseguire l’init senza specificare il file di backend config. Lo state verrà gestito localmente per questo primo giro:
|
1 2 |
terraform init |
ATTENZIONE: Se al primissimo run l’init dovesse fallire a causa del blocco backend azurerm vuoto, commentare temporaneamente il blocco in backend.tf, eseguire init e apply, e ripristinarlo prima del Passo 4.
Passo 3: Eseguire il deployment delle risorse
Generare il piano di esecuzione, verificarlo e applicarlo. Al termine esisteranno il resource group, lo storage account, il container tfstate e il role assignment per l’identità corrente; lo state e’ nel file locale terraform.tfstate:
|
1 2 3 4 |
terraform plan -out=tfplan terraform apply tfplan |
ATTENZIONE: Il ruolo Storage Blob Data Contributor appena assegnato può impiegare alcuni minuti a propagarsi. Se il passo successivo fallisce con errore 403, attendere circa 5 minuti e riprovare.
Passo 4: Migrare lo state nel backend remoto
Rilanciare l’init puntando questa volta al backend remoto tramite il file di configurazione e richiedendo la migrazione dello state esistente:
|
1 2 |
terraform init -backend-config="state.config" -migrate-state |
Terraform chiede conferma per copiare lo state locale nel blob management/rg-tfstate/terraform.tfstate del container. Rispondere yes. Da questo momento il layer è auto-gestito: il suo stesso state vive nello storage che ha creato.
Verifica del deployment
Verificare lo state remoto e rimuovere i file locali
Eseguire un plan di controllo: se lo state remoto e’ coerente con l’infrastruttura, il risultato deve essere “No changes”. A quel punto i file di state locali possono essere eliminati in sicurezza:
|
1 2 3 4 |
terraform plan rm terraform.tfstate terraform.tfstate.backup |
È possibile verificare anche dal portale Azure: nella subscription Management, aprire il resource group rg-tfstate e controllare la presenza dello storage account btechtfbackend001 con i tag applicati da Terraform. Per lo state file, navigare su btechtfbackend001 > Containers > tfstate e verificare la presenza del blob management/rg-tfstate/terraform.tfstate.

Figura 1 — Il resource group rg-tfstate nel portale Azure: lo storage account btechtfbackend001 è presente con i tag applicati da Terraform, insieme all’Event Grid System Topic creato automaticamente a supporto dello storage account.
COMPLETATO: Il backend remoto è operativo. Tutti i layer Terraform del progetto possono ora salvare il proprio state nel container tfstate, ciascuno con una chiave dedicata, con locking automatico e autenticazione Entra ID.
Gestione operativa
Lavorare sul progetto dopo il bootstrap
Per chiunque cloni il repository dopo il bootstrap, il flusso di lavoro è sempre lo stesso:
|
1 2 3 4 5 |
az login az account set --subscription "d519edb8-c5c7-4a14-994d-25379ed5490b" terraform init -backend-config="state.config" terraform plan |
Aggiungere un nuovo layer al progetto
Non serve toccare questo layer: nel nuovo layer si crea un backend.tf con blocco vuoto e un proprio state.config identico a quello visto sopra, cambiando solo il valore di key (per esempio connectivity/hub-vnet/terraform.tfstate). Tutti gli state convivono nello stesso container, ognuno nel proprio blob, seguendo la convenzione <area>/<layer>/terraform.tfstate.
Concedere l’accesso a un nuovo collega o a una pipeline
L’identità deve ricevere il ruolo Storage Blob Data Contributor sullo storage account o sul container. I ruoli classici Reader e Contributor non sono sufficienti per leggere e scrivere blob con autenticazione Entra ID:
|
1 2 |
az role assignment create --assignee <object-id-o-email> --role "Storage Blob Data Contributor" --scope /subscriptions/d519edb8-c5c7-4a14-994d-25379ed5490b/resourceGroups/rg-tfstate/providers/Microsoft.Storage/storageAccounts/btechtfbackend001 |
Sbloccare uno state rimasto in lock
Se un apply viene interrotto bruscamente (crash, chiusura forzata, pipeline terminata), il lease sul blob può restare attivo e i run successivi falliscono segnalando che lo state è già bloccato. Dopo aver verificato che nessun altro stia realmente eseguendo Terraform, forzare lo sblocco usando l’ID riportato nel messaggio di errore:
|
1 2 |
terraform force-unlock <LOCK_ID> |
ATTENZIONE: Forzare l’unlock mentre un apply è realmente in corso corrompe lo state. Usare force-unlock solo dopo aver verificato con il team che non ci siano esecuzioni attive. Inoltre, non modificare mai i blob di state manualmente: per ispezionare lo state usare i comandi terraform state list e terraform state show.
Risoluzione problemi comuni
Error: building account: shared key credentials
Il provider o il backend stanno tentando l’autenticazione con la shared key, che su questo storage è disabilitata. Verificare che storage_use_azuread = true sia presente nel blocco provider azurerm e che use_azuread_auth = true sia presente in state.config, quindi rilanciare:
|
1 2 |
terraform init -reconfigure -backend-config="state.config" |
403 AuthorizationPermissionMismatch
L’identità in uso non ha il ruolo Storage Blob Data Contributor sullo storage account, oppure il ruolo è stato assegnato da poco e non si è ancora propagato. Verificare il role assignment dal portale o con az role assignment list e attendere qualche minuto se appena creato.
Error: state blob is already locked
È rimasto un lease attivo da un run interrotto. Seguire la procedura di sblocco descritta nella sezione di gestione operativa con terraform force-unlock.
Error: Backend configuration changed
Il contenuto di state.config o il blocco backend sono stati modificati rispetto all’ultima init. Rilanciare l’init con -reconfigure per riallineare la configurazione, oppure con -migrate-state se l’intenzione è spostare lo state in una nuova posizione.
Conclusioni
Con pochi file Terraform e due moduli Azure Verified Modules abbiamo realizzato un backend remoto sicuro e pronto per il lavoro in team: state centralizzato in un container blob dedicato, locking automatico, nessuna shared key da custodire e accesso governato esclusivamente da Microsoft Entra ID e Azure RBAC.
Il pattern di partial backend configuration con il file state.config mantiene il codice libero da identificativi hardcodati e rende immediato aggiungere nuovi layer al progetto: basta replicare backend.tf e state.config cambiando la chiave dello state.
NOTA: Come evoluzione futura, valutare la restrizione di rete agli IP degli agenti CI/CD o l’adozione di un private endpoint, l’abilitazione di soft delete e versioning sui blob per facilitare il recovery dello state, e il passaggio a una replica ZRS o GRS se i requisiti di resilienza lo richiedono. Per le pipeline, configurare l’autenticazione OIDC con federated credentials impostando use_oidc = true.