top of page
  • 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:

  1. Export into an empty directory.

  2. Run HCL-only discovery before importing state.

  3. Review the resource mapping.

  4. Rename resources before import where possible.

  5. Treat Terraform state as sensitive.

  6. Require an empty baseline plan.

  7. Do not apply unexplained changes.

  8. Use Terraform for cleanup after adoption.


 
 
 

Comments


bottom of page