Zero Trust Network on Azure with Terraform

Deploy a hub-and-spoke zero trust network on Azure using Terraform.

Introduction

30 Second Summary

Think about how a secure building works. You don't hand every visitor a master key to every room. Instead, you verify who they are, check what they need access to, and only unlock the specific doors they require. That same principle applies to cloud networks.

In this project, you will deploy a zero trust network architecture on Azure using Terraform. You will build a hub-and-spoke topology with locked-down traffic rules and private-only access to cloud storage, all defined as code.

What You'll Build

A hub-and-spoke network on Azure with VNet Peering, Network Security Groups, and a Private Endpoint for storage, all deployed through Terraform configuration files.

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

  • A hub-and-spoke network topology with VNet peering connecting isolated network segments.
  • Network Security Groups enforcing deny-by-default traffic rules between subnets.
  • A Private Endpoint locking down an Azure Storage Account with zero public internet access.
  • Secret Mission: Add a second spoke VNet and prove that spoke-to-spoke traffic is blocked by default.

Are there any prerequisites?

This project assumes foundational networking knowledge (subnets, IP addressing, routing), familiarity with the Azure portal, and comfort using a command line. You do not need prior Terraform experience.

Before We Start

Before We Start...

Set Up Your Terraform Project

Every Infrastructure as Code project starts with a clean directory and a set of configuration files that tell Terraform what to build and where to build it.

In this step, you will create your project structure, configure the AzureRM provider so Terraform knows how to talk to Azure, and authenticate so that your commands can actually create resources.

In this step, get ready to:
  • Create a project directory with three Terraform files.
  • Initialize Terraform and download the AzureRM provider.
  • Authenticate to Azure and confirm your subscription is active.
Create your project directory and Terraform files

Terraform reads .tf files from the current directory to build its execution plan. You need three files: main.tf for resources and provider config, variables.tf for input parameters, and outputs.tf for values Terraform displays after a deployment.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir zerotrust-network
cd zerotrust-network
  • Create the three Terraform files by running:
touch main.tf variables.tf outputs.tf
  • Confirm the files exist by running:
ls

You should see main.tf, variables.tf, and outputs.tf in the output.

Don't see the files?

Make sure you ran cd zerotrust-network before running touch. If ls shows nothing, you may be in the wrong directory.

Run pwd to check your current location. It should end with zerotrust-network.

Having trouble?

  • Open the project in VS Code by running:
code .

What if I use a different editor?

If you use Cursor, IntelliJ, or another editor, open the zerotrust-network folder on your Desktop using that editor's file-open flow instead.

  • Open main.tf in the editor and paste the following code:
# Pin Terraform version and declare the AzureRM provider
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

# Configure the provider with required features block
provider "azurerm" {
  features {}
}

What does this code do?

The terraform block sets two constraints. required_version = ">= 1.5.0" ensures anyone running this code has a modern Terraform CLI. required_providers tells Terraform to download the AzureRM provider from the HashiCorp registry.

The provider "azurerm" block configures how Terraform authenticates to Azure. The empty features {} block is required by the AzureRM provider even if you have no feature flags to set.

  • Save main.tf.
  • Open variables.tf and paste the following code:
# Input variables for the project
variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
  default     = "rg-zerotrust-lab"
}

variable "location" {
  description = "Azure region for all resources"
  type        = string
  default     = "East US"
}

variable "admin_username" {
  description = "Admin username for the test VM"
  type        = string
  default     = "azureuser"
}

# Sensitive variable - Terraform will not display this in output
variable "admin_password" {
  description = "Admin password for the test VM"
  type        = string
  sensitive   = true
}

What does this code do?

Each variable block defines an input that Terraform will prompt you for (or read from a default value) at deploy time. The resource_group_name and location variables set where your resources live in Azure.

The admin_password variable has sensitive = true, which tells Terraform to hide this value from terminal output. It has no default, so Terraform will prompt you for it when you deploy.

  • Save variables.tf.
  • Leave outputs.tf empty for now. You will add output definitions in a later step.

✔️ Awesome, I've got everything!

Great. Double check you have saved both main.tf and variables.tf.

ⓧ I'd like to double check the full code

Here is main.tf:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

Here is variables.tf:

variable "resource_group_name" {
  description = "Name of the resource group"
  type        = string
  default     = "rg-zerotrust-lab"
}

variable "location" {
  description = "Azure region for all resources"
  type        = string
  default     = "East US"
}

variable "admin_username" {
  description = "Admin username for the test VM"
  type        = string
  default     = "azureuser"
}

variable "admin_password" {
  description = "Admin password for the test VM"
  type        = string
  sensitive   = true
}

And outputs.tf should be empty.

Initialize Terraform and verify your setup

With your provider configured, Terraform needs to download the AzureRM plugin before it can do anything else. Initialization also creates a .terraform.lock.hcl file that pins the exact provider version for reproducibility.

  • Download the AzureRM provider by running this command in your terminal (make sure you are inside the zerotrust-network directory):
terraform init

What should I see?

Terraform downloads the AzureRM provider plugin and prints a success message: Terraform has been successfully initialized! You will also see a new .terraform directory and a .terraform.lock.hcl file in your project folder.

Seeing a provider error?

If you see an error about the provider not being found, check that main.tf has the exact source = "hashicorp/azurerm" line. A typo in the source path prevents Terraform from locating the provider in the registry.

Help me debug my terraform init error.

  • Confirm Terraform is working by running:
terraform version

You should see your Terraform version number and the installed AzureRM provider listed underneath.

Authenticate to Azure

Terraform uses your Azure CLI credentials to create resources. You need an active session so that the AzureRM provider can make API calls on your behalf.

  • Sign in to Azure by running:
az login

What happens when I run this?

Your default browser opens with a Microsoft sign-in page. Sign in with the account tied to your Azure subscription. Once authenticated, the browser shows a success message and your terminal displays a list of available subscriptions.

  • Confirm your subscription is active by running:
az account show

You should see JSON output with your subscription name, ID, and a state field showing Enabled. This confirms Terraform can create resources in your subscription.

Subscription state is not Enabled?

If state shows Disabled or you see no output, your subscription may have expired or been deactivated. Check the Azure portal under Subscriptions to verify its status.

Help me fix my Azure subscription issue.

Your Terraform project is wired up and authenticated. Next, you will define the hub-and-spoke network topology that forms the backbone of your zero trust architecture.

Build the Hub-and-Spoke Network

Your Terraform project is initialized and authenticated to Azure. Now it's time to define the core network architecture that every other resource will plug into.

Enterprise networks follow a hub-and-spoke topology. A central hub VNet handles shared connectivity while spoke VNets isolate individual workloads. In this step, you'll codify both VNets in Terraform and connect them with VNet Peering.

In this step, get ready to:
  • Define the resource group and hub VNet with a subnet.
  • Define the spoke VNet with workload and endpoint subnets.
  • Create bidirectional VNet peering between hub and spoke.
Define the resource group and hub VNet

Every Azure resource lives inside a resource group. The hub VNet is your central network segment that will handle shared connectivity for all spokes.

  • In main.tf, find the closing brace of the provider "azurerm" block.
  • Add the following code below it to define the resource group, hub VNet, and hub subnet:
# --- Resource Group ---
resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
}

# --- Hub VNet ---
resource "azurerm_virtual_network" "hub" {
  name                = "vnet-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "hub_default" {
  name                 = "snet-hub-default"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = ["10.0.0.0/24"]
}

What does this code do?

You're defining three resources that form the foundation of your hub network.

  • azurerm_resource_group creates a logical container (rg-zerotrust-lab) for all resources in this project.
  • azurerm_virtual_network creates the hub VNet with a 10.0.0.0/16 address space, giving you 65,536 available IP addresses.
  • azurerm_subnet carves out a 10.0.0.0/24 subnet (256 addresses) within the hub for shared services.

Why hub-and-spoke instead of a flat network?

A flat network puts all workloads on the same VNet. If one workload is compromised, the attacker can move laterally to everything else.

Hub-and-spoke isolates workloads in separate VNets. Traffic between spokes must route through the hub, where you can inspect and control it. Microsoft's Cloud Adoption Framework recommends this as the standard enterprise topology.

  • Save main.tf.

You won't see output yet. The next chunk adds the spoke network, then we'll validate the full configuration.

Define the spoke VNet with workload and endpoint subnets

The spoke VNet isolates your actual workloads from the hub. It needs two subnets: one for compute resources and one dedicated to private endpoints.

  • In main.tf, add the following code below the hub subnet block you just wrote:
# --- Spoke VNet ---
resource "azurerm_virtual_network" "spoke" {
  name                = "vnet-spoke"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.1.0.0/16"]
}

resource "azurerm_subnet" "spoke_workload" {
  name                 = "snet-spoke-workload"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.1.0/24"]
}

resource "azurerm_subnet" "spoke_endpoint" {
  name                 = "snet-spoke-endpoint"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.2.0/24"]
}

What does this code do?

  • vnet-spoke gets its own 10.1.0.0/16 address space, completely separate from the hub's 10.0.0.0/16.
  • snet-spoke-workload (10.1.1.0/24) is where compute resources like VMs will live.
  • snet-spoke-endpoint (10.1.2.0/24) is reserved for Private Endpoints, which give PaaS services a private IP inside the VNet.
  • Save main.tf.
  • Confirm the configuration is valid by running:
terraform validate

You should see Success! The configuration is valid.

Seeing an error from terraform validate?

  • Check for missing closing braces. Each resource block needs its own closing brace on a separate line.
  • Verify that azurerm_virtual_network.spoke.name matches exactly in both subnet blocks. A typo in the reference will break the configuration.
  • Make sure the provider "azurerm" block from Step 1 is still at the top of the file.

Still stuck? Ask for help.

Create VNet peering in both directions

Two VNets on Azure cannot communicate by default, even if their address spaces don't overlap. VNet peering creates a direct, low-latency connection between them over the Azure backbone.

Peering must be configured in both directions. The hub needs a peering to the spoke, and the spoke needs its own peering back to the hub.

  • In main.tf, add the following code below the spoke endpoint subnet:
# --- VNet Peering ---
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "peer-hub-to-spoke"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.spoke.id
  allow_forwarded_traffic   = true
}

resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  name                      = "peer-spoke-to-hub"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.spoke.name
  remote_virtual_network_id = azurerm_virtual_network.hub.id
  allow_forwarded_traffic   = true
}

What does this code do?

  • hub_to_spoke tells the hub VNet about the spoke. Without this, the hub has no route to 10.1.0.0/16.
  • spoke_to_hub tells the spoke VNet about the hub. Peering is not automatic in both directions.
  • allow_forwarded_traffic = true enables traffic that didn't originate in the peer VNet to pass through. This is important when you add an NVA or firewall in the hub later.

Why is VNet peering non-transitive?

If Spoke A peers with the Hub, and Spoke B peers with the Hub, Spoke A and Spoke B still cannot communicate directly. This is by design. VNet peering is non-transitive, meaning traffic does not flow through a peered network to reach a third network.

This is the "assume breach" principle in action. Compromising one workload doesn't give lateral access to another. You'll explore this further in the Secret Mission.

  • Save main.tf.
  • Validate the complete configuration by running:
terraform validate

You should see Success! The configuration is valid.

Validation failing on the peering blocks?

  • Check that remote_virtual_network_id uses .id (not .name). Peering needs the full resource ID, not just the name.
  • Make sure virtual_network_name in each peering block points to the correct VNet. The hub-to-spoke peering lives on the hub VNet (azurerm_virtual_network.hub.name).

Need help debugging? Ask here.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.tf and that terraform validate reports success.

ⓧ I'd like to double check the full code

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

# --- Resource Group ---
resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
}

# --- Hub VNet ---
resource "azurerm_virtual_network" "hub" {
  name                = "vnet-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "hub_default" {
  name                 = "snet-hub-default"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = ["10.0.0.0/24"]
}

# --- Spoke VNet ---
resource "azurerm_virtual_network" "spoke" {
  name                = "vnet-spoke"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.1.0.0/16"]
}

resource "azurerm_subnet" "spoke_workload" {
  name                 = "snet-spoke-workload"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.1.0/24"]
}

resource "azurerm_subnet" "spoke_endpoint" {
  name                 = "snet-spoke-endpoint"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.2.0/24"]
}

# --- VNet Peering ---
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "peer-hub-to-spoke"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.spoke.id
  allow_forwarded_traffic   = true
}

resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  name                      = "peer-spoke-to-hub"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.spoke.name
  remote_virtual_network_id = azurerm_virtual_network.hub.id
  allow_forwarded_traffic   = true
}

Your hub-and-spoke network is defined in code. Next up, you'll add Network Security Groups to enforce deny-by-default traffic rules between these network segments.

Lock Down Traffic with NSGs

Your hub-and-spoke network has connectivity, but right now every port is open and any traffic can flow freely between the VNets. That defeats the purpose of zero trust.

In this step, you will create Network Security Groups (NSGs) that enforce deny-by-default rules on each subnet. Only explicitly allowed traffic will pass through.

In this step, get ready to:
  • Create an NSG for the hub subnet that allows only VNet-to-VNet traffic.
  • Create an NSG for the spoke workload subnet that restricts inbound to SSH from the hub only and blocks outbound internet.
  • Associate each NSG with its respective subnet.
Create the hub NSG with deny-by-default rules

Zero trust means denying all traffic by default, then explicitly allowing only what is needed. The hub subnet needs to accept traffic from peered VNets (so the spoke can communicate through it) but deny everything else.

  • In main.tf, scroll to the bottom of the file (after the VNet Peering section).
  • Add the hub NSG resource by pasting this code:
# --- NSG: Hub ---
resource "azurerm_network_security_group" "hub_nsg" {
  name                = "nsg-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowVNetInbound"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "VirtualNetwork"
    destination_address_prefix = "VirtualNetwork"
  }

  security_rule {
    name                       = "DenyAllInbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

What does this code do?

This creates an NSG named nsg-hub with two rules processed in priority order (lower number = higher priority):

  • Priority 100 (AllowVNetInbound): Permits traffic between resources in any peered VNet. The VirtualNetwork service tag automatically includes all VNet and peered VNet address spaces.
  • Priority 4096 (DenyAllInbound): Catches everything that did not match a higher-priority allow rule and drops it. 4096 is the lowest user-configurable priority in Azure NSGs.
  • Save main.tf.
  • Confirm your syntax is valid by running:
terraform validate

You should see Success! The configuration is valid. in the output.

Seeing an error from validate?

  • Check for missing closing braces. Each security_rule block needs its own } and the outer resource block needs one too.
  • Make sure you pasted the code after the closing } of azurerm_virtual_network_peering.spoke_to_hub, not inside it.
  • help me fix a terraform validate error in my NSG code
Create the spoke workload NSG

The spoke workload subnet needs tighter controls. It should only accept SSH connections from the hub network (so an admin can manage it) and block all outbound internet traffic to prevent data exfiltration.

  • In main.tf, add the spoke NSG resource below the hub NSG you just created:
# --- NSG: Spoke Workload ---
resource "azurerm_network_security_group" "spoke_nsg" {
  name                = "nsg-spoke-workload"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowSSHFromHub"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "10.0.0.0/16"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "DenyAllInbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "DenyInternetOutbound"
    priority                   = 4096
    direction                  = "Outbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "Internet"
  }
}

What does this code do?

This NSG has three rules that work together to enforce least-privilege access:

  • AllowSSHFromHub (priority 100, inbound): Only allows TCP port 22 (SSH) from the hub's address space 10.0.0.0/16. No other inbound traffic from any source is permitted.
  • DenyAllInbound (priority 4096, inbound): Blocks all remaining inbound traffic not matched by the SSH allow rule.
  • DenyInternetOutbound (priority 4096, outbound): Prevents any workload on this subnet from reaching the public internet. The Internet service tag represents all public IP addresses outside Azure.

Why deny outbound internet?

If a workload is compromised, the attacker's first move is usually to phone home to a command-and-control server on the internet. Blocking outbound internet at the network layer stops this lateral movement before it starts.

  • Save main.tf.
  • Run terraform validate again to confirm the new resource is syntactically correct.

You should see Success! The configuration is valid. again.

Validate fails after adding the spoke NSG?

  • Count your security_rule blocks. The spoke NSG should have exactly three rules (SSH allow, deny inbound, deny outbound).
  • Verify that direction for the outbound rule is set to "Outbound", not "Inbound".
  • help me debug my spoke NSG terraform code
Associate NSGs with their subnets

An NSG does nothing until it is associated with a subnet or network interface. This final piece links each NSG to the subnet it should protect.

  • In main.tf, add these two association resources below the spoke NSG:
resource "azurerm_subnet_network_security_group_association" "hub_nsg_assoc" {
  subnet_id                 = azurerm_subnet.hub_default.id
  network_security_group_id = azurerm_network_security_group.hub_nsg.id
}

resource "azurerm_subnet_network_security_group_association" "spoke_nsg_assoc" {
  subnet_id                 = azurerm_subnet.spoke_workload.id
  network_security_group_id = azurerm_network_security_group.spoke_nsg.id
}

What does this code do?

Each azurerm_subnet_network_security_group_association resource binds an NSG to a specific subnet:

  • hub_nsg_assoc attaches nsg-hub to the snet-hub-default subnet.
  • spoke_nsg_assoc attaches nsg-spoke-workload to the snet-spoke-workload subnet.

Once deployed, every packet entering or leaving these subnets will be evaluated against the NSG rules.

  • Save main.tf.
  • Run terraform validate one more time to confirm the full configuration is valid.

You should see Success! The configuration is valid. confirming all your NSG resources are correctly defined.

Still seeing an error?

  • Check that azurerm_subnet.hub_default.id and azurerm_subnet.spoke_workload.id match the resource names you defined in Step 2 exactly.
  • Verify there are no duplicate resource names. Each Terraform resource must have a unique type + name combination.
  • help me fix my NSG association terraform code

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.tf file up to this point:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

# --- Resource Group ---
resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
}

# --- Hub VNet ---
resource "azurerm_virtual_network" "hub" {
  name                = "vnet-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "hub_default" {
  name                 = "snet-hub-default"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = ["10.0.0.0/24"]
}

# --- Spoke VNet ---
resource "azurerm_virtual_network" "spoke" {
  name                = "vnet-spoke"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.1.0.0/16"]
}

resource "azurerm_subnet" "spoke_workload" {
  name                 = "snet-spoke-workload"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.1.0/24"]
}

resource "azurerm_subnet" "spoke_endpoint" {
  name                 = "snet-spoke-endpoint"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.2.0/24"]
}

# --- VNet Peering ---
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "peer-hub-to-spoke"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.spoke.id
  allow_forwarded_traffic   = true
}

resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  name                      = "peer-spoke-to-hub"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.spoke.name
  remote_virtual_network_id = azurerm_virtual_network.hub.id
  allow_forwarded_traffic   = true
}

# --- NSG: Hub ---
resource "azurerm_network_security_group" "hub_nsg" {
  name                = "nsg-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowVNetInbound"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "VirtualNetwork"
    destination_address_prefix = "VirtualNetwork"
  }

  security_rule {
    name                       = "DenyAllInbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_subnet_network_security_group_association" "hub_nsg_assoc" {
  subnet_id                 = azurerm_subnet.hub_default.id
  network_security_group_id = azurerm_network_security_group.hub_nsg.id
}

# --- NSG: Spoke Workload ---
resource "azurerm_network_security_group" "spoke_nsg" {
  name                = "nsg-spoke-workload"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowSSHFromHub"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "10.0.0.0/16"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "DenyAllInbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "DenyInternetOutbound"
    priority                   = 4096
    direction                  = "Outbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "Internet"
  }
}

resource "azurerm_subnet_network_security_group_association" "spoke_nsg_assoc" {
  subnet_id                 = azurerm_subnet.spoke_workload.id
  network_security_group_id = azurerm_network_security_group.spoke_nsg.id
}

Your network now enforces deny-by-default at every subnet boundary. Next up, you will lock down a Storage Account so it is completely invisible to the public internet using a Private Endpoint.

Deploy a Private Endpoint for Storage

Your network now enforces deny-by-default rules with NSGs. But there is still a gap: Azure PaaS services like Storage Accounts are reachable over the public internet by default.

In this step, you will close that gap by creating a Storage Account with zero public access and connecting it to your spoke VNet through a Private Endpoint. You will also configure a Private DNS Zone so that DNS queries resolve to the private IP instead of the public one.

In this step, get ready to:
  • Create a Storage Account locked down from the public internet.
  • Deploy a Private Endpoint connecting the Storage Account to your spoke subnet.
  • Configure Private DNS for blob storage name resolution.
Create a private-only Storage Account

A traditional Storage Account is reachable from anywhere on the internet. By setting public_network_access_enabled = false, you completely eliminate the public path. The only way in is through a Private Endpoint inside your VNet.

  • In your code editor, open main.tf.
  • Scroll to the bottom of the file (after the spoke NSG association block) and add this Storage Account resource:
# --- Storage Account (Private Only) ---
resource "azurerm_storage_account" "private" {
  name                          = "stzerotrust${substr(md5(azurerm_resource_group.main.id), 0, 8)}"
  resource_group_name           = azurerm_resource_group.main.name
  location                      = azurerm_resource_group.main.location
  account_tier                  = "Standard"
  account_replication_type      = "LRS"
  public_network_access_enabled = false
}

What does this code do?

The name uses an md5() hash of the resource group ID to generate a globally unique suffix. Azure Storage Account names must be unique across all of Azure.

Setting public_network_access_enabled = false means nobody can reach this storage from the public internet. Not even you from the Azure Portal. The only access path will be the Private Endpoint you create next.

  • Save main.tf.

You won't see a change yet. The next chunk connects this Storage Account to your VNet via Private Endpoint.

Seeing a syntax error?

Make sure the interpolation ${substr(md5(azurerm_resource_group.main.id), 0, 8)} is inside double quotes with no extra spaces. The entire name value must be a single quoted string.

Help me fix my Storage Account syntax error.

Create a Private Endpoint for blob storage

A Private Endpoint creates a network interface with a private IP inside your VNet. Traffic to the Storage Account routes through this interface instead of the public internet.

  • In main.tf, add the following Private Endpoint resource below the Storage Account you just created:
# --- Private Endpoint for Storage ---
resource "azurerm_private_endpoint" "storage" {
  name                = "pe-storage-blob"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  subnet_id           = azurerm_subnet.spoke_endpoint.id

  private_service_connection {
    name                           = "psc-storage-blob"
    private_connection_resource_id = azurerm_storage_account.private.id
    subresource_names              = ["blob"]
    is_manual_connection           = false
  }

  private_dns_zone_group {
    name                 = "default"
    private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
  }
}

What does this code do?

The subnet_id places the endpoint's network interface in snet-spoke-endpoint (the dedicated endpoint subnet you created in Step 2).

The private_service_connection block targets the Storage Account's blob subresource. This means only Blob Storage traffic routes through the private path.

The private_dns_zone_group block links this endpoint to a Private DNS Zone (which you will define next) so DNS queries automatically resolve to the private IP.

  • Save main.tf.

You won't see a change yet. The Private Endpoint references a DNS zone that does not exist in your code. The next chunk creates it.

Red squiggly under azurerm_private_dns_zone.blob?

This is expected. Your editor may flag azurerm_private_dns_zone.blob.id as an unknown reference because the DNS zone resource does not exist yet. It will resolve once you add the next block.

Is it okay that my code references a resource that doesn't exist yet?

Create a Private DNS Zone for name resolution

Without a Private DNS Zone, a DNS lookup for your storage account's hostname would still resolve to its public IP. The Private DNS Zone overrides this so queries from inside the VNet resolve to the private endpoint's IP instead.

  • In main.tf, add the Private DNS Zone and VNet link below the Private Endpoint resource:
# --- Private DNS Zone ---
resource "azurerm_private_dns_zone" "blob" {
  name                = "privatelink.blob.core.windows.net"
  resource_group_name = azurerm_resource_group.main.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "blob_spoke" {
  name                  = "link-spoke"
  resource_group_name   = azurerm_resource_group.main.name
  private_dns_zone_name = azurerm_private_dns_zone.blob.name
  virtual_network_id    = azurerm_virtual_network.spoke.id
}

What does this code do?

The zone name privatelink.blob.core.windows.net is the official Azure Private DNS zone name for Blob Storage private endpoints. Azure uses this exact name to intercept DNS queries for storage accounts.

The virtual_network_link resource connects this DNS zone to your spoke VNet. Any VM or resource in the spoke VNet can now resolve the storage account's FQDN to its private IP address.

  • Save main.tf.

Now add the output definitions so Terraform reports useful information after deployment.

  • Switch to outputs.tf in your code editor and add the following output blocks:
output "resource_group_name" {
  value = azurerm_resource_group.main.name
}

output "storage_account_name" {
  value = azurerm_storage_account.private.name
}

output "private_endpoint_ip" {
  value = azurerm_private_endpoint.storage.private_service_connection[0].private_ip_address
}

output "test_vm_private_ip" {
  value = azurerm_network_interface.test_vm.private_ip_address
}

What do these outputs do?

Outputs tell Terraform to print key values after a successful terraform apply. You will use the private_endpoint_ip and storage_account_name to verify private access in the next step.

The test_vm_private_ip output references a VM resource you will create in the next step. Terraform will not complain about this until you run terraform plan, which you will do after adding the VM.

  • Save outputs.tf.

Worried about the test_vm reference?

The test_vm_private_ip output references azurerm_network_interface.test_vm which does not exist yet. This is intentional. You will define the VM and its network interface in the next step. Do not run terraform validate or terraform plan until you have added the VM resources.

Will referencing a resource that doesn't exist yet break my Terraform project?

✔️ Awesome, I've got everything!

Great. Double check you have saved both main.tf and outputs.tf.

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

# --- Resource Group ---
resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
}

# --- Hub VNet ---
resource "azurerm_virtual_network" "hub" {
  name                = "vnet-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "hub_default" {
  name                 = "snet-hub-default"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = ["10.0.0.0/24"]
}

# --- Spoke VNet ---
resource "azurerm_virtual_network" "spoke" {
  name                = "vnet-spoke"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.1.0.0/16"]
}

resource "azurerm_subnet" "spoke_workload" {
  name                 = "snet-spoke-workload"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.1.0/24"]
}

resource "azurerm_subnet" "spoke_endpoint" {
  name                 = "snet-spoke-endpoint"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.2.0/24"]
}

# --- VNet Peering ---
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "peer-hub-to-spoke"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.spoke.id
  allow_forwarded_traffic   = true
}

resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  name                      = "peer-spoke-to-hub"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.spoke.name
  remote_virtual_network_id = azurerm_virtual_network.hub.id
  allow_forwarded_traffic   = true
}

# --- NSG: Hub ---
resource "azurerm_network_security_group" "hub_nsg" {
  name                = "nsg-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowVNetInbound"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "VirtualNetwork"
    destination_address_prefix = "VirtualNetwork"
  }

  security_rule {
    name                       = "DenyAllInbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_subnet_network_security_group_association" "hub_nsg_assoc" {
  subnet_id                 = azurerm_subnet.hub_default.id
  network_security_group_id = azurerm_network_security_group.hub_nsg.id
}

# --- NSG: Spoke Workload ---
resource "azurerm_network_security_group" "spoke_nsg" {
  name                = "nsg-spoke-workload"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowSSHFromHub"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "10.0.0.0/16"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "DenyAllInbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "DenyInternetOutbound"
    priority                   = 4096
    direction                  = "Outbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "Internet"
  }
}

resource "azurerm_subnet_network_security_group_association" "spoke_nsg_assoc" {
  subnet_id                 = azurerm_subnet.spoke_workload.id
  network_security_group_id = azurerm_network_security_group.spoke_nsg.id
}

# --- Storage Account (Private Only) ---
resource "azurerm_storage_account" "private" {
  name                          = "stzerotrust${substr(md5(azurerm_resource_group.main.id), 0, 8)}"
  resource_group_name           = azurerm_resource_group.main.name
  location                      = azurerm_resource_group.main.location
  account_tier                  = "Standard"
  account_replication_type      = "LRS"
  public_network_access_enabled = false
}

# --- Private Endpoint for Storage ---
resource "azurerm_private_endpoint" "storage" {
  name                = "pe-storage-blob"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  subnet_id           = azurerm_subnet.spoke_endpoint.id

  private_service_connection {
    name                           = "psc-storage-blob"
    private_connection_resource_id = azurerm_storage_account.private.id
    subresource_names              = ["blob"]
    is_manual_connection           = false
  }

  private_dns_zone_group {
    name                 = "default"
    private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
  }
}

# --- Private DNS Zone ---
resource "azurerm_private_dns_zone" "blob" {
  name                = "privatelink.blob.core.windows.net"
  resource_group_name = azurerm_resource_group.main.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "blob_spoke" {
  name                  = "link-spoke"
  resource_group_name   = azurerm_resource_group.main.name
  private_dns_zone_name = azurerm_private_dns_zone.blob.name
  virtual_network_id    = azurerm_virtual_network.spoke.id
}

And here is the complete outputs.tf:

output "resource_group_name" {
  value = azurerm_resource_group.main.name
}

output "storage_account_name" {
  value = azurerm_storage_account.private.name
}

output "private_endpoint_ip" {
  value = azurerm_private_endpoint.storage.private_service_connection[0].private_ip_address
}

output "test_vm_private_ip" {
  value = azurerm_network_interface.test_vm.private_ip_address
}

Why Private Endpoints instead of Service Endpoints?

Azure offers two ways to restrict PaaS traffic: Service Endpoints and Private Endpoints. Service Endpoints route traffic over the Microsoft backbone but the service still has a public IP. Private Endpoints bring the service into your VNet with its own private IP, fully removing public internet access.

Microsoft recommends Private Endpoints for Zero Trust architectures because they eliminate the public attack surface entirely.

Your Storage Account is now completely locked behind a Private Endpoint with DNS resolution configured. Next up, you will deploy a test VM and bring this entire infrastructure to life with terraform apply.

Deploy and Test with a VM

In the previous step, you locked down a Storage Account behind a Private Endpoint so it has zero public internet exposure. Now you need to prove it actually works.

In this step, you will deploy a test VM inside the spoke workload subnet, deploy the entire infrastructure with terraform apply, and run a DNS lookup from inside the network to confirm that your storage account resolves to a private IP.

In this step, get ready to:
  • Add a test VM to your Terraform configuration.
  • Deploy all resources with terraform plan and terraform apply.
  • Verify private DNS resolution from inside the VM.
Deploy a test VM into the spoke workload subnet

To validate that your zero trust network actually blocks public access and resolves DNS privately, you need a machine inside the network. A small Linux VM in the spoke workload subnet acts as this test client.

  • In your code editor, open main.tf.
  • Scroll to the bottom of the file, below the azurerm_private_dns_zone_virtual_network_link resource.
  • Add the network interface for the test VM by pasting this code:
# --- Test VM ---
resource "azurerm_network_interface" "test_vm" {
  name                = "nic-testvm"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.spoke_workload.id
    private_ip_address_allocation = "Dynamic"
  }
}

What does this code do?

This creates a network interface card (NIC) attached to the spoke workload subnet. The VM will use this NIC to communicate on the network.

The private_ip_address_allocation = "Dynamic" setting lets Azure assign an IP from the 10.1.1.0/24 range automatically. There is no public IP, meaning the VM is completely private.

  • Below the NIC resource, add the Linux VM resource:
resource "azurerm_linux_virtual_machine" "test_vm" {
  name                            = "vm-test-spoke"
  location                        = azurerm_resource_group.main.location
  resource_group_name             = azurerm_resource_group.main.name
  size                            = "Standard_B1s"
  admin_username                  = var.admin_username
  admin_password                  = var.admin_password
  disable_password_authentication = false
  network_interface_ids           = [azurerm_network_interface.test_vm.id]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }
}

What does this code do?

This deploys a Standard_B1s Linux VM (Ubuntu 22.04 LTS) into the spoke workload subnet. It uses password authentication so you can access it without managing SSH keys in this lab.

The admin_password references the sensitive variable you defined in variables.tf earlier. Terraform will prompt you for this value when you run terraform plan or terraform apply.

  • Save main.tf.

Seeing a red squiggly or syntax error?

Make sure both resources are placed after the closing } of the azurerm_private_dns_zone_virtual_network_link resource, not inside it.

Check that every opening { has a matching closing }. A missing brace is the most common HCL syntax error.

Help me find my syntax error

✔️ Awesome, I've got everything!

Great. Double check you have saved main.tf before moving on.

ⓧ I'd like to double check the full code

Here is the complete main.tf at this point:

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

# --- Resource Group ---
resource "azurerm_resource_group" "main" {
  name     = var.resource_group_name
  location = var.location
}

# --- Hub VNet ---
resource "azurerm_virtual_network" "hub" {
  name                = "vnet-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.0.0.0/16"]
}

resource "azurerm_subnet" "hub_default" {
  name                 = "snet-hub-default"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.hub.name
  address_prefixes     = ["10.0.0.0/24"]
}

# --- Spoke VNet ---
resource "azurerm_virtual_network" "spoke" {
  name                = "vnet-spoke"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  address_space       = ["10.1.0.0/16"]
}

resource "azurerm_subnet" "spoke_workload" {
  name                 = "snet-spoke-workload"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.1.0/24"]
}

resource "azurerm_subnet" "spoke_endpoint" {
  name                 = "snet-spoke-endpoint"
  resource_group_name  = azurerm_resource_group.main.name
  virtual_network_name = azurerm_virtual_network.spoke.name
  address_prefixes     = ["10.1.2.0/24"]
}

# --- VNet Peering ---
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  name                      = "peer-hub-to-spoke"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.hub.name
  remote_virtual_network_id = azurerm_virtual_network.spoke.id
  allow_forwarded_traffic   = true
}

resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  name                      = "peer-spoke-to-hub"
  resource_group_name       = azurerm_resource_group.main.name
  virtual_network_name      = azurerm_virtual_network.spoke.name
  remote_virtual_network_id = azurerm_virtual_network.hub.id
  allow_forwarded_traffic   = true
}

# --- NSG: Hub ---
resource "azurerm_network_security_group" "hub_nsg" {
  name                = "nsg-hub"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowVNetInbound"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "VirtualNetwork"
    destination_address_prefix = "VirtualNetwork"
  }

  security_rule {
    name                       = "DenyAllInbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }
}

resource "azurerm_subnet_network_security_group_association" "hub_nsg_assoc" {
  subnet_id                 = azurerm_subnet.hub_default.id
  network_security_group_id = azurerm_network_security_group.hub_nsg.id
}

# --- NSG: Spoke Workload ---
resource "azurerm_network_security_group" "spoke_nsg" {
  name                = "nsg-spoke-workload"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  security_rule {
    name                       = "AllowSSHFromHub"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = "10.0.0.0/16"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "DenyAllInbound"
    priority                   = 4096
    direction                  = "Inbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "DenyInternetOutbound"
    priority                   = 4096
    direction                  = "Outbound"
    access                     = "Deny"
    protocol                   = "*"
    source_port_range          = "*"
    destination_port_range     = "*"
    source_address_prefix      = "*"
    destination_address_prefix = "Internet"
  }
}

resource "azurerm_subnet_network_security_group_association" "spoke_nsg_assoc" {
  subnet_id                 = azurerm_subnet.spoke_workload.id
  network_security_group_id = azurerm_network_security_group.spoke_nsg.id
}

# --- Storage Account (Private Only) ---
resource "azurerm_storage_account" "private" {
  name                          = "stzerotrust${substr(md5(azurerm_resource_group.main.id), 0, 8)}"
  resource_group_name           = azurerm_resource_group.main.name
  location                      = azurerm_resource_group.main.location
  account_tier                  = "Standard"
  account_replication_type      = "LRS"
  public_network_access_enabled = false
}

# --- Private Endpoint for Storage ---
resource "azurerm_private_endpoint" "storage" {
  name                = "pe-storage-blob"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name
  subnet_id           = azurerm_subnet.spoke_endpoint.id

  private_service_connection {
    name                           = "psc-storage-blob"
    private_connection_resource_id = azurerm_storage_account.private.id
    subresource_names              = ["blob"]
    is_manual_connection           = false
  }

  private_dns_zone_group {
    name                 = "default"
    private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
  }
}

# --- Private DNS Zone ---
resource "azurerm_private_dns_zone" "blob" {
  name                = "privatelink.blob.core.windows.net"
  resource_group_name = azurerm_resource_group.main.name
}

resource "azurerm_private_dns_zone_virtual_network_link" "blob_spoke" {
  name                  = "link-spoke"
  resource_group_name   = azurerm_resource_group.main.name
  private_dns_zone_name = azurerm_private_dns_zone.blob.name
  virtual_network_id    = azurerm_virtual_network.spoke.id
}

# --- Test VM ---
resource "azurerm_network_interface" "test_vm" {
  name                = "nic-testvm"
  location            = azurerm_resource_group.main.location
  resource_group_name = azurerm_resource_group.main.name

  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.spoke_workload.id
    private_ip_address_allocation = "Dynamic"
  }
}

resource "azurerm_linux_virtual_machine" "test_vm" {
  name                            = "vm-test-spoke"
  location                        = azurerm_resource_group.main.location
  resource_group_name             = azurerm_resource_group.main.name
  size                            = "Standard_B1s"
  admin_username                  = var.admin_username
  admin_password                  = var.admin_password
  disable_password_authentication = false
  network_interface_ids           = [azurerm_network_interface.test_vm.id]

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }
}
Run terraform plan and terraform apply

Your configuration is complete. Time to preview and deploy all resources in one go.

  • Preview the infrastructure by running this command:
terraform plan

What will Terraform ask for?

Because admin_password has no default value and is marked sensitive, Terraform will prompt you to enter a value. Choose a password that is at least 12 characters long with uppercase, lowercase, numbers, and a special character (Azure enforces this complexity requirement).

Terraform will display (sensitive value) in the plan output instead of showing your password.

  • Enter your chosen admin password when prompted.
  • Review the plan output. You should see a summary showing resources to be created (approximately 14 resources).
  • Deploy the entire infrastructure by running:
terraform apply
  • Enter the same admin password again when prompted.
  • Type yes when Terraform asks you to confirm the apply.

How long does this take?

The deployment typically takes 2-5 minutes. The VM and VNet peering resources take the longest to provision. Terraform will display each resource as it finishes creating.

When complete, Terraform displays your outputs including storage_account_name, private_endpoint_ip, and test_vm_private_ip. Note down the storage_account_name value for the next substep.

  • Confirm the deployment succeeded by checking that Terraform displays a message like Apply complete! Resources: 14 added, 0 changed, 0 destroyed. followed by the output values.

Seeing an error during apply?

If you see a password complexity error, make sure your password has at least 12 characters with a mix of uppercase, lowercase, numbers, and special characters.

If you see a quota or capacity error, try changing the location variable in variables.tf to a different Azure region and run terraform apply again.

Help me troubleshoot my deployment

Verify private DNS resolution from inside the VM

The final test proves your zero trust setup works. You will run a DNS lookup from inside the VM to confirm that the storage account hostname resolves to a private IP address instead of a public one.

  • First, note your storage account name from the Terraform outputs. Retrieve it by running:
terraform output storage_account_name
  • Run an nslookup command on the VM using Azure's Run Command feature. This executes a script directly on the VM through the Azure control plane, without needing SSH access:
az vm run-command invoke -g rg-zerotrust-lab -n vm-test-spoke --command-id RunShellScript --scripts "nslookup $(terraform output -raw storage_account_name).blob.core.windows.net"

What does this command do?

The az vm run-command invoke command executes a script on the VM through the Azure VM agent. It does not require SSH connectivity or a public IP. This is perfect for testing VMs inside locked-down networks.

The nslookup command queries DNS from inside the VM. Because the Private DNS Zone is linked to the spoke VNet, the VM resolves the storage account's blob endpoint to the private IP assigned by the Private Endpoint (in the 10.1.2.x range).

  • Check the output. You should see the storage account name resolve to a private IP address (something in the 10.1.2.x range), confirming that DNS resolves privately.

What should I see?

The output includes a message field showing the nslookup result. Look for a line like Address: 10.1.2.4 (your exact IP may differ). This proves the storage account is only reachable via the private network.

If it resolved to a public IP (e.g., 52.x.x.x), the Private DNS Zone link is not working correctly.

Seeing a public IP or NXDOMAIN?

If the output shows a public IP, verify that your azurerm_private_dns_zone_virtual_network_link resource links to azurerm_virtual_network.spoke.id (not the hub).

If you see NXDOMAIN (name not found), confirm that the private_dns_zone_group block is present inside your azurerm_private_endpoint resource. This block registers the private IP in the DNS zone automatically.

Help me debug my DNS resolution

Your zero trust network is fully deployed and validated. You have proven that the storage account is only accessible via a private IP from inside the network. Next up, try the Secret Mission to add a second spoke and prove true network segmentation.

Secret mission

Add a Second Spoke for True Segmentation

Your network has one spoke, but real enterprises have dozens. Can you add a second spoke and prove that workloads in different spokes are completely isolated from each other? This is the 'assume breach' principle in action.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The VMs and Storage Account in this project incur hourly charges while active.

Cost warning

The two Standard_B1s VMs incur compute charges for every hour they remain running. The Storage Account incurs minimal storage charges. If you are not actively experimenting, choose Pause or Delete below to stop charges immediately.

Resources you used:

  • Azure Resource Group rg-zerotrust-lab (contains all Azure resources below).
  • Hub VNet (vnet-hub), Spoke VNet (vnet-spoke), and Second Spoke VNet (vnet-spoke2) with VNet peering.
  • Network Security Groups (nsg-hub, nsg-spoke-workload, nsg-spoke2-workload).
  • Storage Account with Private Endpoint and Private DNS Zone.
  • Two Linux VMs (vm-test-spoke and vm-test-spoke2, both Standard_B1s).
  • Local Terraform files (main.tf, variables.tf, outputs.tf).

Keep everything running

No action needed. Choose this if you are still actively experimenting with your zero trust network or planning to extend the infrastructure further.

  • Your VMs will continue to incur compute charges (~$0.012/hour each).
  • Storage charges are minimal (fractions of a cent for LRS at this scale).
  • VNet, NSG, Peering, and Private Endpoint resources do not incur hourly charges.

Pause - I'll come back to this later

Deallocate the VMs to stop compute charges while keeping all your networking infrastructure intact. Storage charges still apply, but they are minimal.

  • Deallocate both VMs by running these commands in your terminal:
az vm deallocate --resource-group rg-zerotrust-lab --name vm-test-spoke
az vm deallocate --resource-group rg-zerotrust-lab --name vm-test-spoke2
  • Once deallocated, compute charges stop immediately. Your VNets, NSGs, peering, Private Endpoint, and DNS Zone remain in place at no additional cost.
  • When you return, start the VMs again from the Azure portal or run az vm start --resource-group rg-zerotrust-lab --name vm-test-spoke.

Delete - I don't want to use this again

Remove all project resources and stop all charges immediately. This is irreversible.

  • Destroy all Azure resources by running this command from your project directory:
terraform destroy
  • Terraform will show a plan of all resources to be destroyed. Type yes when prompted to confirm.
  • Once complete, all Azure resources (Resource Group, VNets, NSGs, VMs, Storage Account, Private Endpoint, DNS Zone) are permanently deleted.
  • Delete the local project folder by running:

macOS

cd ~/Desktop
rm -rf zerotrust-network

Windows

cd ~\Desktop
rmdir /s /q zerotrust-network
  • Verify the Azure resources are gone by running:
az group show --name rg-zerotrust-lab
  • You should see an error confirming the resource group no longer exists.

Nice Work!

Nice Work!

Nice work! You just deployed a production-grade zero trust network on Azure, entirely from code.

You've learned how to:

  • Write Terraform configurations from scratch to deploy a hub-and-spoke network topology on Azure with VNet peering.
  • Enforce deny-by-default traffic rules with Network Security Groups and lock down a Storage Account with a Private Endpoint so it has zero public internet exposure.
  • Validate the entire infrastructure with a test VM and tear it all down with a single terraform destroy.
  • Secret Mission: Added a second spoke VNet to prove true network segmentation and non-transitive VNet peering.

Ready to quiz yourself?