Troubleshoot Azure VNet Peering

Troubleshoot Azure VNet peering with Terraform and GitHub Actions.

Introduction

30 Second Summary

A two-way conversation breaks down when only one side can respond. Network connections can hide the same missing half behind a successful deployment.

In this project, you will build a small Microsoft Azure network lab that Terraform deploys through GitHub Actions. You will expose the Initiated state of a directional VNet peering before adding the reciprocal link that moves both directions to Connected.

What You'll Build

Trigger your workflow to watch the lab move from an incomplete one-way peering to two Connected links under version-controlled automation.

By the end of this project, you'll have:

  • A repeatable deployment you can trigger to create vnet-platform plus vnet-app with non-overlapping address spaces.
  • A visible peering diagnosis that captures Initiated before proving why the reciprocal link changes both directions to Connected.
  • A secret-free automation path that uses OIDC with no stored Azure client secret. Remote Terraform state lets every workflow run update the same lab.
  • Secret Mission: Add vnet-analytics as a third network peered only with vnet-app. Use the peering map to prove that vnet-platform gains no transitive link.

Are there any prerequisites?

You need an Azure subscription plus a GitHub account. Your Azure user must be able to create Microsoft Entra applications plus Azure role assignments.

Before We Start

This checkpoint commits you to building automated bidirectional Azure VNet peering. You will deliberately observe the one-way Initiated state so Azure's directional model is clear before both links reach Connected.

Set Up Secure Terraform Automation

Each GitHub Actions runner starts fresh. Terraform therefore needs remote state so every workflow run can update the same infrastructure safely.

The workflow also needs narrowly scoped access to Azure. You will build that foundation with Azure Blob Storage for state plus OIDC for short-lived authentication.

In this step, get ready to:
  • Create a public GitHub repository with a default main branch.
  • Provision the remote state backend plus the federated Azure identity.
  • Configure the repository values and verify the OIDC credential.
Create the repository and launch Cloud Shell

The public GitHub repository holds the Terraform configuration in later steps. Its public REST data also lets the bootstrap process bind OIDC trust to the repository's numeric identity without a personal access token.

  • Open GitHub in your browser.
  • Sign in with your GitHub account.
  • Open the menu in the upper-right corner.
  • Click New repository.
  • Enter azure-vnet-peering-terraform in the Repository name field.
  • Choose Public as the repository visibility.
  • Set Add README to On.
  • Click Create repository.

Your repository now displays README.md on the main branch. That branch becomes the fixed identity used by the federated credential.

Why must the repository be public?

The bootstrap script requests the repository owner's numeric ID plus the repository's numeric ID from GitHub's public REST endpoint. A public repository allows that lookup without a personal access token.

The workflow files remain visible to everyone. Repository secrets remain protected by GitHub.

The Azure bootstrap runs in Azure Cloud Shell. This browser terminal already has Azure CLI access to your signed-in subscription.

  • Open the Azure portal in a new browser tab.
  • Sign in with the account connected to your Azure subscription.
  • Select the Cloud Shell icon in the portal toolbar.
  • Select Bash when Cloud Shell asks which environment to use.
  • Select No storage account required in the Getting started pane.
  • Choose the subscription for this project from the subscription dropdown.
  • Select Apply.

You will see a Bash prompt after the session starts. Keep this Cloud Shell session open because the bootstrap values remain available there.

Cloud Shell did not start?

Confirm that your selected subscription allows Cloud Shell. If the portal asks you to register its resource provider, complete that prompt before reopening the terminal.

If the terminal opens in PowerShell, select Switch to Bash. Help me start Azure Cloud Shell in Bash.

Bootstrap the backend and workload identity

A remote backend preserves Terraform state between ephemeral workflow runners. The same bootstrap also creates a Microsoft Entra application that GitHub can authenticate as.

This setup creates an Azure Storage account that can accrue usage charges. The cleanup section removes the account when you finish the lab.

  • Replace your-github-owner in the first line with your GitHub username.
  • Provision the backend foundation by running the edited chunk in Cloud Shell:
GITHUB_OWNER="your-github-owner"
GITHUB_REPO="azure-vnet-peering-terraform"
LOCATION="eastus"
RESOURCE_GROUP="rg-vnet-peering-lab"
APP_NAME="github-vnet-peering-terraform"

SUBSCRIPTION_ID=$(az account show --query id --output tsv)
TENANT_ID=$(az account show --query tenantId --output tsv)
STORAGE_ACCOUNT="tfstate$(echo "$SUBSCRIPTION_ID" | tr -d '-' | cut -c1-16)"

az group create \
  --name "$RESOURCE_GROUP" \
  --location "$LOCATION"

az storage account create \
  --name "$STORAGE_ACCOUNT" \
  --resource-group "$RESOURCE_GROUP" \
  --location "$LOCATION" \
  --sku Standard_LRS \
  --kind StorageV2 \
  --min-tls-version TLS1_2 \
  --allow-blob-public-access false

What does this bootstrap chunk do?

  • The first group of values fixes the repository name plus the Azure resource names used throughout the project.
  • The account queries capture the active subscription ID plus tenant ID for the GitHub configuration.
  • The storage account name uses the subscription ID prefix to produce a lowercase name within Azure's length limit.
  • The Azure commands create rg-vnet-peering-lab in eastus plus a Standard_LRS state account protected by TLS1_2.
  • Check the first command result for the resource group name rg-vnet-peering-lab.
  • Check the second command result for the generated storage account name plus disabled public blob access.

Backend foundation failed?

Check that Cloud Shell is using the intended subscription. Confirm that your GitHub username replaced the placeholder without extra spaces.

A storage-name conflict means the generated account name already exists globally. Help me diagnose this Azure bootstrap failure.

The next chunk briefly reads a storage account key into the current shell. It removes that value immediately after creating the private state container.

  • Create the private state container by running this chunk in the same Cloud Shell session:
ACCOUNT_KEY=$(az storage account keys list \
  --resource-group "$RESOURCE_GROUP" \
  --account-name "$STORAGE_ACCOUNT" \
  --query "[0].value" \
  --output tsv)

az storage container create \
  --account-name "$STORAGE_ACCOUNT" \
  --name tfstate \
  --account-key "$ACCOUNT_KEY" \
  --auth-mode key

unset ACCOUNT_KEY

How is the account key handled?

The command stores the account key only in the temporary ACCOUNT_KEY shell variable. It uses that value to create the tfstate container.

The final command removes the key from the shell variable. GitHub Actions later accesses the backend through Microsoft Entra OIDC.

  • Check the command result for a successful container creation.
  • Confirm that the result names the tfstate container.

Container creation failed?

Confirm that the storage account command completed successfully. The account key lookup cannot work until that account exists.

Keep every command in the same Cloud Shell session so RESOURCE_GROUP plus STORAGE_ACCOUNT retain their values. Help me fix the state container creation.

The workflow needs an Azure identity before it can request short-lived tokens. The application identifies the automation while its service principal receives permissions inside your subscription.

  • Create the Microsoft Entra application plus its service principal by running this chunk:
APP_ID=$(az ad app create \
  --display-name "$APP_NAME" \
  --query appId \
  --output tsv)

az ad sp create --id "$APP_ID"
SP_OBJECT_ID=$(az ad sp show --id "$APP_ID" --query id --output tsv)

RESOURCE_GROUP_ID="/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP"
STORAGE_ACCOUNT_ID="$RESOURCE_GROUP_ID/providers/Microsoft.Storage/storageAccounts/$STORAGE_ACCOUNT"

What identity does this create?

  • The application named github-vnet-peering-terraform represents the GitHub automation in Microsoft Entra.
  • The service principal represents that application inside the Azure tenant.
  • The object ID targets role assignments at the exact resource-group plus storage-account scopes.
  • Check the terminal result for the new service principal details.
  • Keep the Cloud Shell session open so APP_ID plus SP_OBJECT_ID remain available.

Application creation was denied?

Your Azure user must own the application or hold an eligible Microsoft Entra role. Ask your tenant administrator for application-registration access if the command is denied.

Help me understand my Microsoft Entra application permission error.

Least-privilege access limits the identity to the jobs it performs. Network management belongs at the lab resource group while state access belongs at the storage account.

  • Assign the two scoped roles by running this chunk:
az role assignment create \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Network Contributor" \
  --scope "$RESOURCE_GROUP_ID"

az role assignment create \
  --assignee-object-id "$SP_OBJECT_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope "$STORAGE_ACCOUNT_ID"

Why are the roles scoped differently?

Network Contributor permits the workflow to manage the VNets plus peerings inside rg-vnet-peering-lab. It does not grant virtual machine management.

Storage Blob Data Contributor permits Terraform to read plus update the state blob. The assignment applies only to the generated state storage account.

  • Check the first result for the Network Contributor role at the resource-group scope.
  • Check the second result for the Storage Blob Data Contributor role at the storage-account scope.

Azure can require a short propagation window before new role assignments become usable. A brief authorization delay after successful assignment output is expected.

Role assignment failed?

Confirm that your account has Microsoft.Authorization/roleAssignments/write at both target scopes. The service principal can exist even when your user lacks permission to assign its roles.

Help me diagnose my Azure role assignment permissions.

An immutable OIDC subject ties the Azure trust to the exact GitHub owner ID plus repository ID. Renaming another repository to the same visible name cannot inherit this trust.

  • Create the immutable federated credential by running this chunk in the same Cloud Shell session:
REPO_JSON=$(curl -sS "https://api.github.com/repos/$GITHUB_OWNER/$GITHUB_REPO")
OWNER_ID=$(echo "$REPO_JSON" | jq -r '.owner.id')
REPO_ID=$(echo "$REPO_JSON" | jq -r '.id')

cat > credential.json <<EOF
{
  "name": "github-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:${GITHUB_OWNER}@${OWNER_ID}/${GITHUB_REPO}@${REPO_ID}:ref:refs/heads/main",
  "description": "GitHub Actions main branch",
  "audiences": ["api://AzureADTokenExchange"]
}
EOF

az ad app federated-credential create \
  --id "$APP_ID" \
  --parameters credential.json

rm credential.json

How does the federated trust work?

  • The GitHub REST request retrieves the numeric owner ID plus repository ID.
  • The subject restricts trust to the exact repository's main branch.
  • The issuer identifies GitHub's Actions token service.
  • The final command creates github-main before deleting the temporary credential file.
  • Check the command result for the credential name github-main.
  • Check that its subject ends with ref:refs/heads/main.

Federated credential creation failed?

Confirm that the repository is public. Check that GITHUB_OWNER contains your GitHub username without the placeholder.

A missing numeric ID usually means the GitHub REST request could not find the repository. Help me debug the immutable OIDC subject.

The final bootstrap output maps each Azure value to the GitHub setting that needs it. None of these values is an Azure client secret.

  • Print the five repository settings by running this command:
printf 'AZURE_CLIENT_ID=%s\nAZURE_TENANT_ID=%s\nAZURE_SUBSCRIPTION_ID=%s\nAZURE_RESOURCE_GROUP=%s\nAZURE_STORAGE_ACCOUNT=%s\n' \
  "$APP_ID" "$TENANT_ID" "$SUBSCRIPTION_ID" "$RESOURCE_GROUP" "$STORAGE_ACCOUNT"

What should this output contain?

You will see five lines. Each line starts with the GitHub setting name followed by its Azure value.

Keep this terminal visible while you configure the repository. Copy only the value after each equals sign.

Seeing blank values?

Blank output means the Cloud Shell session lost one or more bootstrap variables. Return to the start of the bootstrap in a fresh Bash session before configuring GitHub.

Help me recover the Azure bootstrap values.

Configure GitHub and verify the trust

Repository secrets provide the Azure identity values to the workflow. Repository variables provide the non-sensitive resource names used by the backend plus Terraform input.

Why OIDC instead of a client secret?

OIDC lets GitHub request a short-lived Azure token for an approved repository subject. The workflow never stores a long-lived Azure client secret.

The federated subject plus scoped roles define who can authenticate and what that identity can manage.

  • Return to the azure-vnet-peering-terraform repository on GitHub.
  • Click Settings in the repository navigation.
  • Select Secrets and variables in the sidebar.
  • Select Actions.
  • Click New repository secret in the Secrets section.
  • Enter AZURE_CLIENT_ID in the Name field.
  • Paste the matching Cloud Shell output into the Secret field.
  • Click Add secret.

One secret is saved

GitHub now shows the AZURE_CLIENT_ID entry without revealing its stored value. Use the same form for the remaining identity values.

  • Repeat the repository-secret form for AZURE_TENANT_ID using its matching output value.
  • Repeat the repository-secret form for AZURE_SUBSCRIPTION_ID using its matching output value.
  • Select the Variables tab.
  • Click New repository variable.
  • Enter AZURE_RESOURCE_GROUP in the Name field.
  • Paste the matching Cloud Shell output into the Value field.
  • Click Add variable.
  • Repeat the repository-variable form for AZURE_STORAGE_ACCOUNT using its matching output value.

Repository settings do not match?

Check every setting name character by character. GitHub workflow references depend on the exact uppercase names shown in the Cloud Shell output.

Keep resource names in repository variables. Keep the three Azure identity values in repository secrets. Help me check my GitHub Actions settings.

The final check reads the federated credentials attached to the application. Before you run it, do you expect the application to show the exact GitHub branch trust you created?

  • Return to the same Azure Cloud Shell session.
  • Verify the federated credential by running this command:
az ad app federated-credential list --id "$APP_ID" --output table

What does this check prove?

The table shows the federated credentials registered on the Microsoft Entra application. You should see the github-main credential.

That entry confirms Azure trusts OIDC tokens from the exact repository's main branch.

Your automation foundation is ready. GitHub now has a remote state backend plus a scoped Azure identity without a stored client secret.

Credential missing from the table?

Confirm that the credential-creation command completed in this Cloud Shell session. Check that APP_ID still refers to the github-vnet-peering-terraform application.

Help me find my missing github-main federated credential.

The secure control plane is in place. Next, you will add the Terraform configuration and deploy two isolated VNets through GitHub Actions.

Deploy Two Isolated VNets

Your secure identity and remote state backend are ready. Now your GitHub Actions workflow needs Terraform files that describe the first Azure VNets.

This first deployment establishes two valid address spaces that remain isolated. Their distinct CIDR ranges give you a clean baseline before peering changes the connectivity model.

In this step, get ready to:
  • Configure version-pinned Terraform access to the remote state backend.
  • Define two VNets with non-overlapping address spaces.
  • Deploy the VNets through GitHub Actions and verify them in Azure Cloud Shell.
Configure Terraform and OIDC

Each GitHub-hosted runner starts with a fresh environment. The Azure Blob Storage backend preserves state between those runs.

The provider uses OIDC to request short-lived Azure access through the trust you created earlier. The version pins keep every workflow run on the same Terraform and AzureRM releases.

  • Return to the azure-vnet-peering-terraform repository from the previous step.
  • Select Add file above the repository file list.
  • Select Create new file.
  • Enter versions.tf in the file name field.
  • Configure the Terraform version pins and backend by pasting this code into the editor:
terraform {
  required_version = "= 1.16.3"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "= 5.6.0"
    }
  }

  backend "azurerm" {
    use_oidc         = true
    use_azuread_auth = true
  }
}

What does this configuration do?

  • The required_version setting pins Terraform to 1.16.3.
  • The required_providers block pins the AzureRM provider to 5.6.0.
  • The backend block enables OIDC access to the Azure Blob backend.
  • Select Commit changes....
  • Enter Configure Terraform versions and backend in the Commit message field.
  • Select the option that commits to the current main branch.
  • Select Commit changes.

You should see versions.tf in the repository file list. The backend contract is now stored on main.

Cannot commit versions.tf?

Confirm that you are signed into the account that owns the repository. Check that the file name is exactly versions.tf.

If GitHub still blocks the commit, help me troubleshoot the versions.tf commit.

  • Return to the repository file list.
  • Select Add file.
  • Select Create new file.
  • Enter provider.tf in the file name field.
  • Configure the AzureRM provider by pasting this code into the editor:
provider "azurerm" {
  features {}
  use_oidc = true
}

How does the provider authenticate?

  • The features block activates the AzureRM provider configuration.
  • The use_oidc setting tells the provider to use the workflow's federated identity.
  • Select Commit changes....
  • Enter Configure AzureRM provider in the Commit message field.
  • Select the option that commits to the current main branch.
  • Select Commit changes.

You should now see provider.tf beside versions.tf.

Provider file missing?

Return to the repository's main branch. Confirm that the latest commit contains provider.tf.

If the file landed on another branch, help me move provider.tf to main.

  • Return to the repository file list.
  • Select Add file.
  • Select Create new file.
  • Enter variables.tf in the file name field.
  • Declare the existing resource-group input by pasting this code into the editor:
variable "resource_group_name" {
  type        = string
  description = "Existing Azure resource group for the VNet peering lab."
}

Why use a variable here?

The resource_group_name variable lets the workflow supply the existing resource-group name at runtime. This keeps the configuration tied to the repository variable you created earlier.

  • Select Commit changes....
  • Enter Add resource group variable in the Commit message field.
  • Select the option that commits to the current main branch.
  • Select Commit changes.

You should see all three Terraform configuration files on main.

Variable file not listed?

Check that the file is named variables.tf with the plural form. Confirm that you committed it to main.

If the repository still shows an older file list, help me find my variables.tf commit.

Define two isolated VNets

The resource group already exists, so Terraform reads it as a data source. Both VNets inherit its Azure location.

The platform VNet uses 10.10.0.0/16. The app VNet uses 10.20.0.0/16.

  • Return to the repository file list.
  • Select Add file.
  • Select Create new file.
  • Enter main.tf in the file name field.
  • Define the resource-group lookup and both VNets by pasting this code into the editor:
data "azurerm_resource_group" "lab" {
  name = var.resource_group_name
}

resource "azurerm_virtual_network" "platform" {
  name                = "vnet-platform"
  location            = data.azurerm_resource_group.lab.location
  resource_group_name = data.azurerm_resource_group.lab.name
  address_space       = ["10.10.0.0/16"]
}

resource "azurerm_virtual_network" "app" {
  name                = "vnet-app"
  location            = data.azurerm_resource_group.lab.location
  resource_group_name = data.azurerm_resource_group.lab.name
  address_space       = ["10.20.0.0/16"]
}

What does this network configuration do?

  • The azurerm_resource_group.lab data source reads the existing lab resource group.
  • The azurerm_virtual_network.platform resource defines vnet-platform with the 10.10.0.0/16 address space.
  • The azurerm_virtual_network.app resource defines vnet-app with the 10.20.0.0/16 address space.
  • The distinct address spaces satisfy Azure's non-overlap requirement for future peering.
  • Select Commit changes....
  • Enter Define isolated VNets in the Commit message field.
  • Select the option that commits to the current main branch.
  • Select Commit changes.

You should see main.tf on main with two VNet resource blocks. The file contains no peering resources.

Main configuration looks different?

Check the spelling of platform and app. Confirm that each address space contains a different second octet.

If the HCL structure is unclear, help me compare my main.tf VNet blocks.

  • Return to the repository file list.
  • Select Add file.
  • Select Create new file.
  • Enter outputs.tf in the file name field.
  • Expose both VNet resource IDs by pasting this code into the editor:
output "vnet_ids" {
  description = "Azure resource IDs for both virtual networks."
  value = {
    platform = azurerm_virtual_network.platform.id
    app      = azurerm_virtual_network.app.id
  }
}

Why expose the VNet IDs?

The vnet_ids output groups the resource IDs under stable platform and app keys. These IDs identify the deployed VNets in Azure.

  • Select Commit changes....
  • Enter Add VNet outputs in the Commit message field.
  • Select the option that commits to the current main branch.
  • Select Commit changes.

You should now see outputs.tf beside the other Terraform files.

Output references not matching?

Confirm that the output references azurerm_virtual_network.platform.id and azurerm_virtual_network.app.id exactly.

If the resource references still look inconsistent, help me align outputs.tf with main.tf.

Build and run the deployment workflow

The workflow turns the Terraform files into a repeatable deployment. It initializes the remote backend before producing a saved plan.

The saved plan gives you a review point before Azure changes. Workflow concurrency also keeps multiple runs from changing the same state simultaneously.

  • Return to the repository file list.
  • Select Add file.
  • Select Create new file.
  • Enter .github/workflows/terraform.yml in the file name field.
  • Create the first runnable workflow slice by pasting this code into the editor:
name: Terraform VNet Peering

on:
  workflow_dispatch:

permissions:
  id-token: write
  contents: read

concurrency:
  group: terraform-vnet-peering
  cancel-in-progress: false

jobs:
  terraform:
    runs-on: ubuntu-latest
    env:
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      ARM_USE_OIDC: "true"
      ARM_USE_AZUREAD: "true"
      TF_VAR_resource_group_name: ${{ vars.AZURE_RESOURCE_GROUP }}

    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1

What does this workflow slice establish?

  • The workflow_dispatch trigger makes the workflow manually runnable.
  • The permission block allows GitHub to request an OIDC token.
  • The concurrency group keeps one Terraform run active at a time.
  • The environment block maps your repository secrets and variables into Terraform's Azure settings.
  • The checkout action gives the runner access to the repository files.
  • Select Commit changes....
  • Enter Create Terraform workflow in the Commit message field.
  • Select the option that commits to the current main branch.
  • Select Commit changes.

You should see the .github/workflows directory in the repository. GitHub should also list Terraform VNet Peering under Actions.

Workflow not listed?

Confirm that the file path is exactly .github/workflows/terraform.yml on the main branch.

If the workflow remains hidden, help me troubleshoot GitHub workflow discovery.

  • Select .github/workflows/terraform.yml from the repository file tree.
  • Select the pencil icon above the file.
  • Find this trigger block:
on:
  workflow_dispatch:

What are you changing?

The current trigger allows manual runs without an operation choice. The next block adds the workflow input used by the deployment steps.

  • Replace the trigger block with this operation input:
on:
  workflow_dispatch:
    inputs:
      operation:
        description: Choose whether to apply or destroy the lab
        required: true
        default: apply
        type: choice
        options:
          - apply
          - destroy

How does the operation input work?

The choice input limits manual runs to apply or destroy. The default keeps this deployment run focused on creating the lab.

  • Scroll to the checkout step.
  • Add the setup and validation steps below the checkout action by pasting this code:
      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v4.0.1
        with:
          terraform_version: "1.16.3"

      - name: Check Terraform formatting
        run: terraform fmt -check

      - name: Initialize Terraform
        run: >-
          terraform init -input=false
          -backend-config="storage_account_name=${{ vars.AZURE_STORAGE_ACCOUNT }}"
          -backend-config="container_name=tfstate"
          -backend-config="key=vnet-peering.tfstate"

      - name: Validate Terraform
        run: terraform validate

What do these steps prepare?

  • The setup action installs Terraform 1.16.3 on the hosted runner.
  • The formatting check catches inconsistent Terraform formatting before deployment.
  • The initialization step connects to the tfstate/vnet-peering.tfstate remote state location.
  • The validation step checks the Terraform configuration before planning.
  • Select Commit changes....
  • Enter Add Terraform initialization in the Commit message field.
  • Select the option that commits to the current main branch.
  • Select Commit changes.

The workflow file should now contain checkout, setup, formatting, initialization, and validation steps.

YAML indentation looks uneven?

Keep every workflow step aligned beneath steps. Keep each uses or run line aligned beneath its step name.

If GitHub reports invalid workflow syntax, help me debug my workflow indentation.

  • Select .github/workflows/terraform.yml from the repository file tree.
  • Select the pencil icon above the file.
  • Scroll below the validation step.
  • Add the plan and apply branches by pasting this code:
      - name: Create apply plan
        if: inputs.operation == 'apply'
        run: terraform plan -input=false -out=tfplan

      - name: Apply saved plan
        if: inputs.operation == 'apply'
        run: terraform apply -input=false tfplan

      - name: Create destroy plan
        if: inputs.operation == 'destroy'
        run: terraform plan -destroy -input=false -out=destroy.tfplan

      - name: Apply destroy plan
        if: inputs.operation == 'destroy'
        run: terraform apply -input=false destroy.tfplan

How does the workflow protect each operation?

  • The apply branch saves a plan as tfplan before applying that exact plan.
  • The destroy branch saves a separate plan as destroy.tfplan.
  • Each if condition runs only the branch selected through the manual input.
  • Select Commit changes....
  • Enter Add Terraform plan and apply operations in the Commit message field.
  • Select the option that commits to the current main branch.
  • Select Commit changes.

The complete workflow should now appear on main with separate apply and destroy paths.

Operation steps missing?

Confirm that the four operation steps sit under the existing steps list. Check that their indentation matches the checkout step.

If the conditions are not recognized, help me debug my operation steps.

✔️ Awesome, I've got everything!

Great. Your six project files should now be saved on the main branch.

ⓧ I'd like to double check the full code

terraform {
  required_version = "= 1.16.3"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "= 5.6.0"
    }
  }

  backend "azurerm" {
    use_oidc         = true
    use_azuread_auth = true
  }
}

What to compare in versions.tf

Confirm the Terraform and AzureRM version pins. Check that both backend authentication settings are enabled.

provider "azurerm" {
  features {}
  use_oidc = true
}

What to compare in provider.tf

Confirm that the AzureRM provider contains the empty features block. Check that use_oidc is set to true.

variable "resource_group_name" {
  type        = string
  description = "Existing Azure resource group for the VNet peering lab."
}

What to compare in variables.tf

Confirm that the variable name matches TF_VAR_resource_group_name from the workflow. Check that its type is string.

data "azurerm_resource_group" "lab" {
  name = var.resource_group_name
}

resource "azurerm_virtual_network" "platform" {
  name                = "vnet-platform"
  location            = data.azurerm_resource_group.lab.location
  resource_group_name = data.azurerm_resource_group.lab.name
  address_space       = ["10.10.0.0/16"]
}

resource "azurerm_virtual_network" "app" {
  name                = "vnet-app"
  location            = data.azurerm_resource_group.lab.location
  resource_group_name = data.azurerm_resource_group.lab.name
  address_space       = ["10.20.0.0/16"]
}

What to compare in main.tf

Confirm that main.tf contains one resource-group data source and two VNet resources. Check that no peering block appears in this baseline.

output "vnet_ids" {
  description = "Azure resource IDs for both virtual networks."
  value = {
    platform = azurerm_virtual_network.platform.id
    app      = azurerm_virtual_network.app.id
  }
}

What to compare in outputs.tf

Confirm that the output contains the platform and app resource IDs.

name: Terraform VNet Peering

on:
  workflow_dispatch:
    inputs:
      operation:
        description: Choose whether to apply or destroy the lab
        required: true
        default: apply
        type: choice
        options:
          - apply
          - destroy

permissions:
  id-token: write
  contents: read

concurrency:
  group: terraform-vnet-peering
  cancel-in-progress: false

jobs:
  terraform:
    runs-on: ubuntu-latest
    env:
      ARM_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
      ARM_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
      ARM_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
      ARM_USE_OIDC: "true"
      ARM_USE_AZUREAD: "true"
      TF_VAR_resource_group_name: ${{ vars.AZURE_RESOURCE_GROUP }}

    steps:
      - name: Check out repository
        uses: actions/checkout@v7.0.1

      - name: Set up Terraform
        uses: hashicorp/setup-terraform@v4.0.1
        with:
          terraform_version: "1.16.3"

      - name: Check Terraform formatting
        run: terraform fmt -check

      - name: Initialize Terraform
        run: >-
          terraform init -input=false
          -backend-config="storage_account_name=${{ vars.AZURE_STORAGE_ACCOUNT }}"
          -backend-config="container_name=tfstate"
          -backend-config="key=vnet-peering.tfstate"

      - name: Validate Terraform
        run: terraform validate

      - name: Create apply plan
        if: inputs.operation == 'apply'
        run: terraform plan -input=false -out=tfplan

      - name: Apply saved plan
        if: inputs.operation == 'apply'
        run: terraform apply -input=false tfplan

      - name: Create destroy plan
        if: inputs.operation == 'destroy'
        run: terraform plan -destroy -input=false -out=destroy.tfplan

      - name: Apply destroy plan
        if: inputs.operation == 'destroy'
        run: terraform apply -input=false destroy.tfplan

What to compare in the workflow

Confirm that the workflow passes all six Azure and Terraform environment values. Check that initialization targets tfstate/vnet-peering.tfstate.

Confirm that the workflow plans before applying. Check that the apply and destroy branches use separate saved plan files.

Before you start the deployment, do you expect the plan to create any peering links?

  • Select the Actions tab in the repository.
  • Select Terraform VNet Peering from the workflow list.
  • Select Run workflow.
  • Select the main branch.
  • Choose apply for the operation input.
  • Select Run workflow to start the deployment.

The first run can take a few minutes while the hosted runner installs Terraform and Azure finishes recognizing the role assignments. A short wait here is expected.

  • Select the new workflow run.
  • Select the terraform job.
  • Inspect Create apply plan after it finishes.

The plan should contain two VNet additions. It should contain no peering resources.

  • Confirm that Apply saved plan completes successfully.

That deployment is the first working slice of your network lab. GitHub Actions has now created both isolated VNets from a reviewed Terraform plan.

Workflow run failed?

Open the first failed step to identify whether the problem comes from formatting, backend access, or Azure authorization. Confirm that all three repository secrets and both repository variables from the previous step still exist.

A recent Azure role assignment can take time to propagate. Wait briefly before rerunning the same workflow.

If the run still fails, help me debug my Terraform VNet workflow.

Before you run the final checks, do you expect the two VNets to show the same address range or two distinct ranges?

  • Switch back to Azure Cloud Shell from the previous step.
  • Verify both deployed address spaces by running these commands:
az network vnet show --resource-group "$RESOURCE_GROUP" --name vnet-platform --query "{name:name,addressSpace:addressSpace.addressPrefixes}" --output table

az network vnet show --resource-group "$RESOURCE_GROUP" --name vnet-app --query "{name:name,addressSpace:addressSpace.addressPrefixes}" --output table

What do these checks prove?

Each command reads one deployed VNet from rg-vnet-peering-lab. The query keeps the output focused on its name and address prefix.

You should see vnet-platform with 10.10.0.0/16. You should see vnet-app with 10.20.0.0/16.

VNet verification failed?

Confirm that the workflow reached the successful apply step. Check that RESOURCE_GROUP still refers to rg-vnet-peering-lab in your Cloud Shell session.

If one VNet is missing, help me compare Azure with my Terraform plan.

Your isolated network baseline is live. Next, you will add one directional peering link and watch Azure expose the missing reciprocal connection.

Create a One-Way Peering

Your GitHub Actions workflow has already deployed two Azure virtual networks through Terraform. Their address spaces do not overlap.

Azure VNet peering is directional. Each Terraform peering resource defines one side of a relationship.

This step intentionally creates only the platform-to-app direction. The incomplete relationship exposes the Initiated state before you add the reciprocal link.

In this step, get ready to:
  • Add the platform-to-app peering resource to main.tf.
  • Expose the new peering ID through outputs.tf.
  • Prove the one-way link stops at Initiated.
Add the platform-to-app link

A peering resource names its local VNet. It also points to the remote VNet by resource ID.

  • In GitHub, switch back to the azure-vnet-peering-terraform repository.
  • Select main.tf from the repository file list.
  • Click the pencil icon button in the upper-right corner of the file view.
  • Place your cursor after the closing brace of azurerm_virtual_network.app.
  • Paste this resource block on a new line:
resource "azurerm_virtual_network_peering" "platform_to_app" {
  name                         = "peer-platform-to-app"
  resource_group_name          = data.azurerm_resource_group.lab.name
  virtual_network_name         = azurerm_virtual_network.platform.name
  remote_virtual_network_id    = azurerm_virtual_network.app.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = false
  allow_gateway_transit        = false
  use_remote_gateways          = false
}

What does this resource do?

  • The virtual_network_name value makes vnet-platform the local side of the link.
  • The remote_virtual_network_id value points the link toward vnet-app.
  • The allow_virtual_network_access value permits traffic across a completed peering.
  • The remaining access flags keep forwarded traffic and gateway transit disabled.
  • This single resource defines only the platform-to-app direction.
  • Click the Preview tab above the editor.
  • Confirm the diff contains only the new platform_to_app resource block.
  • Click Commit changes below the editor.
  • Enter Add one-way VNet peering as the commit message.
  • Click Commit changes in the dialog.
  • Return to the main.tf file view.
  • Confirm the file ends with the platform_to_app resource.

Good progress. Your configuration now describes one directional link from the platform VNet to the app VNet.

Does the main.tf diff look different?

Confirm you added the block after the complete azurerm_virtual_network.app resource. Existing VNet blocks should remain unchanged.

If GitHub reports a formatting problem later, compare every brace and value with the reference below. Help me check the one-way peering block.

✔️ Awesome, I've got everything!

Your main.tf file now contains the two VNets plus one directional peering resource.

ⓧ I'd like to double check the full code

data "azurerm_resource_group" "lab" {
  name = var.resource_group_name
}

resource "azurerm_virtual_network" "platform" {
  name                = "vnet-platform"
  location            = data.azurerm_resource_group.lab.location
  resource_group_name = data.azurerm_resource_group.lab.name
  address_space       = ["10.10.0.0/16"]
}

resource "azurerm_virtual_network" "app" {
  name                = "vnet-app"
  location            = data.azurerm_resource_group.lab.location
  resource_group_name = data.azurerm_resource_group.lab.name
  address_space       = ["10.20.0.0/16"]
}

resource "azurerm_virtual_network_peering" "platform_to_app" {
  name                         = "peer-platform-to-app"
  resource_group_name          = data.azurerm_resource_group.lab.name
  virtual_network_name         = azurerm_virtual_network.platform.name
  remote_virtual_network_id    = azurerm_virtual_network.app.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = false
  allow_gateway_transit        = false
  use_remote_gateways          = false
}
Expose the peering ID and deploy

A Terraform output makes the new Azure resource ID visible after deployment. The partial output contains only the one direction that exists in this step.

  • Return to the repository file list.
  • Select outputs.tf.
  • Click the pencil icon button in the upper-right corner of the file view.
  • Place your cursor after the existing vnet_ids output block.
  • Paste this output block on a new line:
output "peering_ids" {
  description = "Azure resource ID for the one-way peering link."
  value = {
    platform_to_app = azurerm_virtual_network_peering.platform_to_app.id
  }
}

What does this output expose?

  • The peering_ids output groups peering resource IDs under descriptive keys.
  • The platform_to_app key exposes the ID created by the new peering resource.
  • The output contains one entry because the reciprocal resource does not exist yet.
  • Click the Preview tab above the editor.
  • Confirm the diff contains only the new peering_ids output.
  • Click Commit changes below the editor.
  • Enter Expose one-way peering ID as the commit message.
  • Click Commit changes in the dialog.
  • Confirm outputs.tf now displays both output blocks.

Is the peering output missing?

Make sure the new block sits outside the closing brace of vnet_ids. Each output needs its own complete block.

Check that platform_to_app matches the resource name in main.tf. Help me check my Terraform outputs.

✔️ Awesome, I've got everything!

Your outputs.tf file now exposes both VNet IDs plus the one-way peering ID.

ⓧ I'd like to double check the full code

output "vnet_ids" {
  description = "Azure resource IDs for both virtual networks."
  value = {
    platform = azurerm_virtual_network.platform.id
    app      = azurerm_virtual_network.app.id
  }
}

output "peering_ids" {
  description = "Azure resource ID for the one-way peering link."
  value = {
    platform_to_app = azurerm_virtual_network_peering.platform_to_app.id
  }
}

Before you start the workflow, predict how many new infrastructure resources the plan will propose.

  • Select the Actions tab in the repository.
  • Select Terraform VNet Peering from the workflow list.
  • Click Run workflow.
  • Select the main branch.
  • Choose apply for the operation input.
  • Click Run workflow to start the deployment.

The plan is your safety check before Azure changes. It should identify the directional peering as the only new infrastructure resource.

  • Open the newest workflow run.
  • Expand Create apply plan after that step finishes.
  • Confirm the plan lists azurerm_virtual_network_peering.platform_to_app as the only new resource.
  • Expand Apply saved plan.
  • Wait for the workflow run to report success.

Did the workflow fail?

If the formatting or validation step fails, compare main.tf and outputs.tf with the full-file references above.

If Azure rejects the request, allow time for the role assignment from the setup step to propagate. Rerun the same apply workflow without changing the Terraform configuration.

For another failure, help me debug the one-way peering workflow.

That deployment is complete. GitHub Actions has created exactly one peering resource while preserving both VNets.

Observe the Initiated state

Azure keeps the one-sided peering incomplete because the app VNet has no matching link back to the platform VNet. The peering state makes that missing direction visible.

Before you run the check, consider whether Azure can show a complete connection after only one direction.

  • Return to the Azure Cloud Shell Bash session from earlier.
  • Check the one-way peering state by running this command:
az network vnet peering show --resource-group "$RESOURCE_GROUP" --vnet-name vnet-platform --name peer-platform-to-app --query peeringState --output tsv

Why does Azure report Initiated?

The command prints Initiated because Azure can see the platform-to-app peering. The reciprocal app-to-platform link is still missing.

Azure reports Connected only after both directional links exist. This controlled shortfall proves that one peering resource cannot complete the relationship.

You should see Initiated on its own line. That result confirms the deliberate one-way deployment worked exactly as intended.

Did you get a different result?

Confirm the GitHub Actions run completed successfully. A failed apply leaves no peering for Azure Cloud Shell to inspect.

Check that the resource group is rg-vnet-peering-lab. Check that the peering name is peer-platform-to-app.

If the command still returns an unexpected result, help me debug the Azure peering state.

You have made Azure's directional peering model visible. The platform-to-app link exists while the overall relationship remains incomplete.

Your controlled failure is now in place. Next, you will add the reciprocal resource and watch both directions move to Connected.

Complete Bidirectional Peering

Your one-way Azure VNet peering exposed the directional model. Azure reports peer-platform-to-app as Initiated because the reverse link is still missing.

A reciprocal Terraform resource completes the connection. A second GitHub Actions run will then prove that the finished configuration produces no further changes.

In this step, get ready to:
  • Add the reciprocal app-to-platform peering resource.
  • Expose both directional peering IDs in the Terraform output.
  • Verify both links as connected before proving the configuration is idempotent.
Add the reciprocal peering resource

Each VNet needs its own directional peering resource. The reciprocal resource points from vnet-app back to vnet-platform.

  • Return to the azure-vnet-peering-terraform repository on GitHub.
  • Select main.tf from the repository file list.
  • Select the pencil icon in the upper-right corner of the file view.
  • Place your cursor after the closing brace of azurerm_virtual_network_peering.platform_to_app.
  • Add the reciprocal peering resource by copying this block:
resource "azurerm_virtual_network_peering" "app_to_platform" {
  name                         = "peer-app-to-platform"
  resource_group_name          = data.azurerm_resource_group.lab.name
  virtual_network_name         = azurerm_virtual_network.app.name
  remote_virtual_network_id    = azurerm_virtual_network.platform.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = false
  allow_gateway_transit        = false
  use_remote_gateways          = false

  depends_on = [azurerm_virtual_network_peering.platform_to_app]
}

What does this resource do?

  • The virtual_network_name value makes vnet-app the source of this directional link.
  • The remote_virtual_network_id value points the link toward vnet-platform.
  • The access flags match the existing peering resource.
  • The depends_on list serializes the two peering resources because AzureRM cannot operate on VNet peerings concurrently.
  • Select Preview above the file contents.
  • Select Commit changes....
  • Enter Add reciprocal VNet peering in the Commit message field.
  • Choose the current main branch as the commit destination.
  • Select Commit changes.
  • Return to main.tf on the main branch.

The new azurerm_virtual_network_peering.app_to_platform block now appears below the original peering resource. Your repository contains both directions of the connection.

Reciprocal block missing?

Confirm that the repository view shows the main branch. Check the commit history for your reciprocal peering commit.

If the commit is present on another branch, apply the change to main before running the workflow. Help me find why my reciprocal peering block is missing from main.

✔️ Awesome, I've got everything!

Great. Double-check that main.tf is committed to the main branch.

ⓧ I'd like to double check the full code

data "azurerm_resource_group" "lab" {
  name = var.resource_group_name
}

resource "azurerm_virtual_network" "platform" {
  name                = "vnet-platform"
  location            = data.azurerm_resource_group.lab.location
  resource_group_name = data.azurerm_resource_group.lab.name
  address_space       = ["10.10.0.0/16"]
}

resource "azurerm_virtual_network" "app" {
  name                = "vnet-app"
  location            = data.azurerm_resource_group.lab.location
  resource_group_name = data.azurerm_resource_group.lab.name
  address_space       = ["10.20.0.0/16"]
}

resource "azurerm_virtual_network_peering" "platform_to_app" {
  name                         = "peer-platform-to-app"
  resource_group_name          = data.azurerm_resource_group.lab.name
  virtual_network_name         = azurerm_virtual_network.platform.name
  remote_virtual_network_id    = azurerm_virtual_network.app.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = false
  allow_gateway_transit        = false
  use_remote_gateways          = false
}

resource "azurerm_virtual_network_peering" "app_to_platform" {
  name                         = "peer-app-to-platform"
  resource_group_name          = data.azurerm_resource_group.lab.name
  virtual_network_name         = azurerm_virtual_network.app.name
  remote_virtual_network_id    = azurerm_virtual_network.platform.id
  allow_virtual_network_access = true
  allow_forwarded_traffic      = false
  allow_gateway_transit        = false
  use_remote_gateways          = false

  depends_on = [azurerm_virtual_network_peering.platform_to_app]
}
Expose both peering IDs

The current output describes only the original link. The final output maps both resource IDs so each direction remains visible after deployment.

  • Return to the repository file list.
  • Select outputs.tf.
  • Select the pencil icon in the upper-right corner of the file view.
  • Remove the existing peering_ids output block.
  • Replace it with the final output block below:
output "peering_ids" {
  description = "Azure resource IDs for both directional peering links."
  value = {
    platform_to_app = azurerm_virtual_network_peering.platform_to_app.id
    app_to_platform = azurerm_virtual_network_peering.app_to_platform.id
  }
}

What does this output expose?

  • The platform_to_app entry retains the ID of the original link.
  • The app_to_platform entry exposes the ID of the reciprocal link.
  • The two entries make the directional structure visible in Terraform output.
  • Select Preview above the file contents.
  • Select Commit changes....
  • Enter Expose both peering IDs in the Commit message field.
  • Choose the current main branch as the commit destination.
  • Select Commit changes.
  • Return to outputs.tf on the main branch.

The peering_ids map now contains both directional entries. The committed configuration is ready for its next apply run.

Output still shows one link?

Confirm that you replaced the complete peering_ids block. Check that app_to_platform points to azurerm_virtual_network_peering.app_to_platform.id.

Help me compare my peering output with the expected Terraform configuration.

✔️ Awesome, I've got everything!

Good. Double-check that outputs.tf is committed to the main branch.

ⓧ I'd like to double check the full code

output "vnet_ids" {
  description = "Azure resource IDs for both virtual networks."
  value = {
    platform = azurerm_virtual_network.platform.id
    app      = azurerm_virtual_network.app.id
  }
}

output "peering_ids" {
  description = "Azure resource IDs for both directional peering links."
  value = {
    platform_to_app = azurerm_virtual_network_peering.platform_to_app.id
    app_to_platform = azurerm_virtual_network_peering.app_to_platform.id
  }
}
Verify connected and idempotent state

The committed code now describes both sides of the peering. The next workflow run should add only the missing reciprocal resource.

Before you start the run, how many new peering resources do you expect the Terraform plan to propose?

  • Select the Actions tab in your repository.
  • Select the Terraform VNet Peering workflow.
  • Select Run workflow.
  • Keep main as the selected branch.
  • Choose apply for the operation input.
  • Select Run workflow to start the deployment.

The workflow creates a saved plan before applying it. The run then adds the reciprocal peering resource.

  • Select the completed workflow run.
  • Expand the Create apply plan step.
  • Confirm that the plan creates exactly one peering resource.
  • Expand the Apply saved plan step.
  • Confirm that the saved plan completed successfully.

The plan contains the single missing app_to_platform resource. Your existing VNets remain in place.

Workflow run failed?

Check the formatting step for a mismatch in either edited file. Compare the reciprocal block with the full main.tf reference above.

If the plan reports a peering operation problem, confirm that depends_on references azurerm_virtual_network_peering.platform_to_app. Help me debug my reciprocal peering workflow run.

Azure now has both directional resources. The peering-state checks reveal whether the control plane has joined them into one connected relationship.

Before you run these checks, do you expect the two directional states to match?

  • Switch back to Azure Cloud Shell Bash.
  • Check both peering directions by running these commands:
az network vnet peering show \
  --resource-group "$RESOURCE_GROUP" \
  --vnet-name vnet-platform \
  --name peer-platform-to-app \
  --query peeringState \
  --output tsv

az network vnet peering show \
  --resource-group "$RESOURCE_GROUP" \
  --vnet-name vnet-app \
  --name peer-app-to-platform \
  --query peeringState \
  --output tsv

What do these checks prove?

  • The first command reads the state of the link from vnet-platform to vnet-app.
  • The second command reads the state of the link from vnet-app to vnet-platform.
  • Matching results confirm that Azure recognizes the complete bidirectional relationship.

You will see Connected twice. That is the hard part complete: Azure now recognizes both directional links.

Still seeing Initiated?

Confirm that the workflow applied the app_to_platform resource successfully. Check that its source VNet is vnet-app.

Rerun the checks after the completed apply. Help me diagnose why one Azure VNet peering still shows Initiated.

One final run tests whether the configuration is stable. An unchanged plan proves that Terraform can apply the same desired state repeatedly.

Before you run the workflow again, do you expect Terraform to propose another resource change?

  • Return to the Terraform VNet Peering workflow in the repository's Actions tab.
  • Select Run workflow.
  • Keep main as the selected branch.
  • Choose apply for the operation input.
  • Select Run workflow.
  • Select the completed workflow run.
  • Expand the Create apply plan step.
  • Confirm that the plan reports no infrastructure changes.

The second apply reports no infrastructure changes. You have demonstrated idempotence with the same version-pinned configuration.

What is idempotence?

An idempotent infrastructure configuration converges on the same desired state every time. Reapplying it leaves working resources unchanged when the code has not changed.

Secret mission

Demonstrate Non-Transitive Peering

Extend the lab with vnet-analytics and connect it only to the app VNet. The resulting peering map proves that the platform VNet does not inherit a connection to analytics.

Clean Up Your Resources

Clean Up Your Resources

Azure Virtual Network itself is free. Decide whether to keep, pause, or delete the lab because the Azure Blob Storage backend can still accrue storage and operation charges.

Cost warning

The state account is billed for stored data and operations. Traffic is billed only when data crosses a VNet peering connection.

Pausing stops new workflow runs. It leaves the state account billable.

Choose Delete when you have finished using the lab.

Resources you used:

  • Azure resource group rg-vnet-peering-lab containing vnet-platform, vnet-app, and vnet-analytics.
  • Two directional peering resources between the platform and app VNets.
  • Two directional peering resources between the app and analytics VNets.
  • One Standard LRS storage account with the private tfstate container holding vnet-peering.tfstate.
  • One Microsoft Entra application named github-vnet-peering-terraform.
  • One service principal with the github-main federated OIDC credential.
  • Two scoped Azure RBAC role assignments for network and storage access.
  • Three repository secrets used by the GitHub Actions workflow.
  • Two repository variables used by Terraform for the resource group and storage backend.

Keep everything running

No action needed. Choose this if you are still using the network lab or plan to extend it.

  • Leave the three VNets and four directional peering resources in place.
  • Keep the storage backend so future workflow runs can access the existing Terraform state.
  • Keep the Microsoft Entra identity so the workflow can continue authenticating without a stored client secret.
  • Leave the repository secrets and variables configured for future runs.
  • Plan for possible storage charges while the state account remains provisioned.

Pause - I'll come back to this later

Stop triggering the workflow while you are away. Your network topology and Terraform state remain ready for another session.

  • Leave the manually triggered workflow idle.
  • Keep rg-vnet-peering-lab provisioned so Terraform can manage the same resources later.
  • Keep the Microsoft Entra application and repository configuration unchanged.
  • Allow for possible storage charges while the state account remains provisioned.

Delete - I don't want to use this again

Removing these resources is permanent. The cleanup order protects the remote Terraform state until the workflow finishes its destroy run.

  • In your GitHub repository, select Actions.
  • Select Terraform VNet Peering in the left sidebar.
  • Click Run workflow.
  • Select main in the branch menu.
  • Choose destroy for operation.
  • Click Run workflow to start the cleanup.
  • Open the new workflow run.
  • Wait until every job step shows success.

That is the risky part handled. Terraform has removed the three VNets and four directional peering links.

  • Return to Azure Cloud Shell Bash from earlier.
  • Delete the Microsoft Entra identity and Azure resource group by running this script:
APP_ID=$(az ad app list \
  --display-name github-vnet-peering-terraform \
  --query "[0].appId" \
  --output tsv)

az ad app delete --id "$APP_ID"
az group delete --name rg-vnet-peering-lab --yes --no-wait

What does this cleanup script do?

  • The APP_ID lookup finds the Microsoft Entra application by its display name.
  • The application deletion tears down the identity created for this lab.
  • The resource-group deletion removes the storage backend and any remaining resources inside rg-vnet-peering-lab.
  • The --no-wait flag returns control to Cloud Shell while Azure completes the resource-group deletion.
  • Confirm that the script returns to the Cloud Shell prompt without reporting an error.

Cleanup command blocked?

If the application lookup returns no value, confirm Cloud Shell is using the tenant where github-vnet-peering-terraform was created.

If Azure reports a permission problem, use an account that can delete the application and resource group.

Help me diagnose my Azure cleanup failure.

Finish the cleanup by removing the GitHub repository values that pointed to the deleted Azure resources.

  • Return to Settings in your GitHub repository.
  • Select Secrets and variables.
  • Select Actions.
  • Remove AZURE_CLIENT_ID from Secrets.
  • Remove AZURE_TENANT_ID from Secrets.
  • Remove AZURE_SUBSCRIPTION_ID from Secrets.

The workflow no longer holds the Azure identity values used for OIDC authentication.

  • Select Variables.
  • Remove AZURE_RESOURCE_GROUP.
  • Remove AZURE_STORAGE_ACCOUNT.

Your source files remain in GitHub as a reference. The workflow no longer has the identity or backend values needed to deploy the lab.

Nice Work!

Nice Work!

That is a strong finish. Your Azure network lab now demonstrates directional peering through a secure Infrastructure as Code workflow.

What you learned:

  • Use CIDR address planning to deploy two Azure VNets with non-overlapping address spaces through version-pinned Terraform.
  • Automate the full Terraform lifecycle with GitHub Actions. Protect remote state in Azure Blob Storage through OIDC authentication with least-privilege Azure roles.
  • Diagnose one-way VNet peering through the Initiated state. Add the reciprocal resource to reach Connected on both links. Confirm an idempotent second apply.
  • Complete the optional Secret Mission by extending the topology with a third analytics VNet. Use the resulting peering map to prove non-transitive peering.

Ready to quiz yourself?