- 14 hours ago
- 8 min read
Azure Export for Terraform Workshop: Brownfield Hub-and-Spoke Network

In this workshop, you will create a small Azure hub-and-spoke network with Azure CLI and adopt it into Terraform using Microsoft Azure Export for Terraform, aztfexport.
You will:
deploy an Azure hub-and-spoke environment outside Terraform;
discover the existing Azure resources;
generate Terraform configuration;
import the resources into Terraform state;
validate a no-change Terraform plan;
make a controlled Terraform change;
test drift detection;
remove the lab.
Workshop overview
Item | Value |
Level | Intermediate |
Duration | 45–60 minutes |
Shell | PowerShell 7 |
Region | West Europe |
Tools | Azure CLI, Terraform, aztfexport |
Topology | One hub VNet and two spoke VNets |
Architecture
Resource group: rg-aztfexport-hubspoke-weu
Hub VNet
10.0.0.0/16
└── snet-shared
10.0.1.0/24
Application spoke
10.10.0.0/16
└── snet-app
10.10.1.0/24
└── Application NSG
Data spoke
10.20.0.0/16
└── snet-data
10.20.1.0/24
└── Data NSG
Peerings:
Hub <──> Application spoke
Hub <──> Data spoke
This lab does not deploy virtual machines, Azure Firewall, VPN Gateway, Bastion, public IP addresses, or NAT Gateway.
1. Prerequisites
You need:
an Azure subscription;
PowerShell 7;
Azure CLI;
Terraform CLI;
Azure Export for Terraform;
permission to create Azure networking resources.
Install the tools on Windows:
winget install --id Microsoft.PowerShell --exact
winget install --id Microsoft.AzureCLI --exact
winget install --id Hashicorp.Terraform --exact
winget install --id Microsoft.Azure.AztfExport --exact
Verify the tools:
pwsh --version
az version
terraform version
aztfexport --version
Review the available commands:
aztfexport --help
aztfexport resource-group --help
aztfexport mapping-file --help
2. Sign in to Azure
Sign in:
az login
List your subscriptions:
az account list `
--query "[].{Name:name,SubscriptionId:id,Default:isDefault}" `
--output table
Select the subscription:
$SubscriptionId = "<your-subscription-id>"
az account set `
--subscription $SubscriptionId
Confirm the selected subscription:
az account show `
--query "{Name:name,SubscriptionId:id,TenantId:tenantId}" `
--output table
3. Deploy the brownfield environment
The following environment is created with Azure CLI rather than Terraform. This simulates an existing Azure environment that you want to adopt.
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$SubscriptionId = az account show --query id -o tsv
$Location = "westeurope"
$ResourceGroup = "rg-aztfexport-hubspoke-weu"
$HubVnet = "vnet-hub-aztfexport-weu"
$AppSpokeVnet = "vnet-spoke-app-aztfexport-weu"
$DataSpokeVnet = "vnet-spoke-data-aztfexport-weu"
$AppNsg = "nsg-spoke-app-aztfexport-weu"
$DataNsg = "nsg-spoke-data-aztfexport-weu"
az account set `
--subscription $SubscriptionId
Create the resource group:
az group create `
--subscription $SubscriptionId `
--name $ResourceGroup `
--location $Location `
--tags `
workshop=aztfexport `
topology=hub-spoke `
managedBy=brownfield `
--output none
Create the hub VNet:
az network vnet create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--location $Location `
--name $HubVnet `
--address-prefixes "10.0.0.0/16" `
--subnet-name "snet-shared" `
--subnet-prefixes "10.0.1.0/24" `
--tags `
workshop=aztfexport `
topology=hub-spoke `
managedBy=brownfield `
--output none
Create the application spoke:
az network vnet create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--location $Location `
--name $AppSpokeVnet `
--address-prefixes "10.10.0.0/16" `
--subnet-name "snet-app" `
--subnet-prefixes "10.10.1.0/24" `
--tags `
workshop=aztfexport `
topology=hub-spoke `
managedBy=brownfield `
--output none
Create the data spoke:
az network vnet create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--location $Location `
--name $DataSpokeVnet `
--address-prefixes "10.20.0.0/16" `
--subnet-name "snet-data" `
--subnet-prefixes "10.20.1.0/24" `
--tags `
workshop=aztfexport `
topology=hub-spoke `
managedBy=brownfield `
--output none
Create the network security groups:
foreach ($nsg in $AppNsg, $DataNsg) {
az network nsg create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--location $Location `
--name $nsg `
--tags `
workshop=aztfexport `
topology=hub-spoke `
managedBy=brownfield `
--output none
}
Create an inbound HTTPS rule on both NSGs:
foreach ($nsg in $AppNsg, $DataNsg) {
az network nsg rule create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--nsg-name $nsg `
--name "Allow-Hub-HTTPS" `
--priority 100 `
--direction Inbound `
--access Allow `
--protocol Tcp `
--source-address-prefixes "10.0.0.0/16" `
--source-port-ranges "*" `
--destination-address-prefixes "*" `
--destination-port-ranges 443 `
--description "Allow HTTPS traffic from the hub." `
--output none
}
Associate the application NSG with the application subnet:
az network vnet subnet update `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vnet-name $AppSpokeVnet `
--name "snet-app" `
--network-security-group $AppNsg `
--output none
Associate the data NSG with the data subnet:
az network vnet subnet update `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vnet-name $DataSpokeVnet `
--name "snet-data" `
--network-security-group $DataNsg `
--output none
Resolve the VNet resource IDs:
$HubVnetId = az network vnet show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $HubVnet `
--query id `
--output tsv
$AppSpokeVnetId = az network vnet show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $AppSpokeVnet `
--query id `
--output tsv
$DataSpokeVnetId = az network vnet show `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $DataSpokeVnet `
--query id `
--output tsv
Create the hub-to-application-spoke peering:
az network vnet peering create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vnet-name $HubVnet `
--name "peer-hub-to-app" `
--remote-vnet $AppSpokeVnetId `
--allow-vnet-access true `
--allow-forwarded-traffic true `
--output none
Create the application-spoke-to-hub peering:
az network vnet peering create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vnet-name $AppSpokeVnet `
--name "peer-app-to-hub" `
--remote-vnet $HubVnetId `
--allow-vnet-access true `
--allow-forwarded-traffic true `
--output none
Create the hub-to-data-spoke peering:
az network vnet peering create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vnet-name $HubVnet `
--name "peer-hub-to-data" `
--remote-vnet $DataSpokeVnetId `
--allow-vnet-access true `
--allow-forwarded-traffic true `
--output none
Create the data-spoke-to-hub peering:
az network vnet peering create `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vnet-name $DataSpokeVnet `
--name "peer-data-to-hub" `
--remote-vnet $HubVnetId `
--allow-vnet-access true `
--allow-forwarded-traffic true `
--output none
Display a completion message:
Write-Host `
"Brownfield hub-and-spoke environment deployed." `
-ForegroundColor Green
4. Validate the Azure environment
List the VNets:
az network vnet list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--query "[].{
Name:name,
AddressSpace:addressSpace.addressPrefixes[0],
Subnets:length(subnets),
Peerings:length(virtualNetworkPeerings)
}" `
--output table
Expected result:
Name AddressSpace Subnets Peerings
------------------------------------ -------------- --------- --------
vnet-hub-aztfexport-weu 10.0.0.0/16 1 2
vnet-spoke-app-aztfexport-weu 10.10.0.0/16 1 1
vnet-spoke-data-aztfexport-weu 10.20.0.0/16 1 1
Check the peering states:
foreach ($vnet in $HubVnet, $AppSpokeVnet, $DataSpokeVnet) {
Write-Host "`n$vnet" -ForegroundColor Cyan
az network vnet peering list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--vnet-name $vnet `
--query "[].{Name:name,State:peeringState}" `
--output table
}
The peerings should report:
Connected
List all resources in the resource group:
az resource list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--query "[].{Name:name,Type:type}" `
--output table
5. Create the export workspace
Create one directory for discovery and another for the managed Terraform configuration.
$LabRoot = Join-Path $HOME "aztfexport-hub-spoke"
$DiscoveryDir = Join-Path $LabRoot "discovery"
$ManagedDir = Join-Path $LabRoot "managed"
Remove-Item `
$LabRoot `
-Recurse `
-Force `
-ErrorAction SilentlyContinue
New-Item `
-ItemType Directory `
-Path $DiscoveryDir `
-Force |
Out-Null
New-Item `
-ItemType Directory `
-Path $ManagedDir `
-Force |
Out-Null
Always use an empty directory for a new export.
6. Discover resources without creating Terraform state
Run an HCL-only export:
aztfexport resource-group `
--subscription-id $SubscriptionId `
--non-interactive `
--plain-ui `
--hcl-only `
--config-mode=lossless `
--mask-sensitive `
--output-dir $DiscoveryDir `
$ResourceGroup
This command generates Terraform configuration and a resource mapping, but it does not import the resources into Terraform state.
List the generated files:
Get-ChildItem `
$DiscoveryDir `
-Force |
Format-Table Name, Length
The important mapping file is:
aztfexportResourceMapping.json
Load the mapping:
$MappingPath = Join-Path `
$DiscoveryDir `
"aztfexportResourceMapping.json"
$Mapping = Get-Content `
$MappingPath `
-Raw |
ConvertFrom-Json -AsHashtable
"Discovered resources: $($Mapping.Count)"
Display the generated Terraform resource types and names:
$Mapping.GetEnumerator() |
ForEach-Object {
[pscustomobject]@{
TerraformType = $_.Value.resource_type
TerraformName = $_.Value.resource_name
AzureId = $_.Value.resource_id
}
} |
Sort-Object TerraformType, TerraformName |
Format-Table TerraformType, TerraformName -AutoSize
A mapping entry looks similar to this:
{
"resource_id": "/subscriptions/.../virtualNetworks/vnet-hub-aztfexport-weu",
"resource_type": "azurerm_virtual_network",
"resource_name": "res-0"
}
The resulting Terraform address is:
azurerm_virtual_network.res-0
You can edit the resource_name property before import.
For example:
{
"resource_id": "/subscriptions/.../virtualNetworks/vnet-hub-aztfexport-weu",
"resource_type": "azurerm_virtual_network",
"resource_name": "hub"
}
This produces:
azurerm_virtual_network.hub
Renaming resources before import is easier than renaming Terraform state later.
7. Generate Terraform configuration and state
Use the reviewed mapping file to perform the full export:
aztfexport mapping-file `
--subscription-id $SubscriptionId `
--non-interactive `
--plain-ui `
--config-mode=lossless `
--mask-sensitive `
--output-dir $ManagedDir `
$MappingPath
List the generated files:
Get-ChildItem `
$ManagedDir `
-Force |
Format-Table Name, Length
The directory should contain:
Terraform configuration files
Terraform state
Provider configuration
Resource mapping
Terraform state can contain sensitive information. Do not commit it to Git or paste it into support tickets.
8. Validate the Terraform import
Move into the managed directory:
Set-Location $ManagedDir
Format the generated configuration:
terraform fmt -recursive
Initialize Terraform:
terraform init
Validate the configuration:
terraform validate
List the imported resources:
terraform state list |
Sort-Object
Run the baseline plan:
terraform plan `
-input=false `
-detailed-exitcode `
-out=baseline.tfplan
$PlanExitCode = $LASTEXITCODE
Interpret the exit code:
switch ($PlanExitCode) {
0 {
Write-Host `
"PASS: Terraform detected no changes." `
-ForegroundColor Green
}
1 {
Write-Host `
"FAIL: Terraform plan returned an error." `
-ForegroundColor Red
}
2 {
Write-Host `
"WARNING: Terraform detected differences." `
-ForegroundColor Yellow
terraform show `
-no-color `
baseline.tfplan
}
}
Delete the saved plan:
Remove-Item `
.\baseline.tfplan `
-Force `
-ErrorAction SilentlyContinue
The expected result is exit code 0.
Do not automatically apply a non-empty first plan. Review every proposed change.
9. Test Terraform ownership
Search the generated Terraform files for the original tag:
Get-ChildItem `
-Filter "*.tf" |
Select-String `
-Pattern "managedBy|brownfield"
Change this value in the generated Terraform configuration:
managedBy = "brownfield"
to:
managedBy = "terraform"
Create a plan:
terraform plan `
-out=ownership.tfplan
Review the plan:
terraform show `
-no-color `
ownership.tfplan
The plan should update tags without replacing the networking resources.
Apply the reviewed plan:
terraform apply ownership.tfplan
Delete the plan file:
Remove-Item `
.\ownership.tfplan `
-Force
Confirm the resource-group tag:
az group show `
--subscription $SubscriptionId `
--name $ResourceGroup `
--query "tags.managedBy" `
--output tsv
Expected result:
terraform
10. Test drift detection
Create a manual change outside Terraform:
az network vnet update `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--name $AppSpokeVnet `
--set tags.drift=manual `
--output none
Run a Terraform plan:
terraform plan `
-detailed-exitcode
$DriftExitCode = $LASTEXITCODE
"Terraform plan exit code: $DriftExitCode"
Expected result:
2
Terraform should detect the manually added tag because it is not declared in the Terraform configuration.
Restore the declared state:
terraform apply
Run another plan:
terraform plan `
-detailed-exitcode
$LASTEXITCODE
Expected result:
0
11. Basic troubleshooting
Authentication problems
Check the active account:
az account show
Test access-token retrieval:
az account get-access-token `
--resource https://management.azure.com/
Sign in again when required:
az logout
az login
az account set `
--subscription $SubscriptionId
Output directory is not empty
Remove and recreate the discovery directory:
Remove-Item `
$DiscoveryDir `
-Recurse `
-Force
New-Item `
-ItemType Directory `
-Path $DiscoveryDir |
Out-Null
Avoid using --overwrite unless you understand which files will be replaced.
Resources are missing
Check for skipped resources:
$SkippedFile = Join-Path `
$DiscoveryDir `
"aztfexportSkippedResources.txt"
if (Test-Path $SkippedFile) {
Get-Content $SkippedFile
}
Compare the export with Azure:
az resource list `
--subscription $SubscriptionId `
--resource-group $ResourceGroup `
--query "[].{Name:name,Type:type}" `
--output table
The first Terraform plan is not empty
Create a readable plan:
terraform plan `
-out=investigate.tfplan
Display it:
terraform show `
-no-color `
investigate.tfplan
Inspect an affected Terraform resource:
terraform state show `
<terraform-resource-address>
Inspect the matching Azure resource:
az resource show `
--ids "<azure-resource-id>" `
--output jsonc
Common causes include:
Azure-generated values;
provider defaults;
masked sensitive values;
unsupported properties;
resources changing during export;
dependencies outside the export scope;
different provider versions.
Do not use ignore_changes merely to hide an unexplained difference.
12. Cleanup
Terraform should be the primary cleanup method because Terraform now manages the imported resources.
Create a destroy plan:
terraform plan `
-destroy `
-parallelism=1 `
-out=destroy.tfplan
Review it:
terraform show `
-no-color `
destroy.tfplan
Apply the destroy plan:
terraform apply `
-parallelism=1 `
destroy.tfplan
Delete the saved plan:
Remove-Item `
.\destroy.tfplan `
-Force
Check whether the resource group still exists:
az group exists `
--subscription $SubscriptionId `
--name $ResourceGroup
Expected result:
false
When the resource group was not imported or still exists, remove it with Azure CLI:
az group delete `
--subscription $SubscriptionId `
--name $ResourceGroup `
--yes
Summary
The workflow used in this workshop is:
Create brownfield Azure resources
↓
Run an HCL-only export
↓
Review the resource mapping
↓
Run the mapping-file export
↓
Generate Terraform state
↓
Require a no-change Terraform plan
↓
Make a controlled Terraform change
↓
Test drift detection
↓
Clean up through Terraform
The most important rules are:
Export into an empty directory.
Run HCL-only discovery before importing state.
Review the resource mapping.
Rename resources before import where possible.
Treat Terraform state as sensitive.
Require an empty baseline plan.
Do not apply unexplained changes.
Use Terraform for cleanup after adoption.
Comments