Zero Trust Network on Azure with Terraform
Deploy a segmented Zero Trust network on Azure using Terraform and NSGs.
Introduction
30 Second Summary
Think about a traditional office building. Once someone gets past the front door, they can roam freely between floors and rooms. That's how flat networks work.
In this project, you will build a Zero Trust network environment on Azure using Terraform. You'll deploy segmented virtual networks, lock down a storage account so it's only reachable through a private IP, and enforce security guardrails that block insecure configurations at the control plane.
What You'll Build
After running terraform apply, you'll have a fully codified network where nothing is trusted by default, every access path is explicitly granted, and an Azure Policy actively blocks anyone from creating insecure resources.
By the end of this project, you'll have:
- A hub-spoke VNet topology where no cross-network traffic flows unless explicitly permitted by Network Security Group rules.
- A Storage Account that starts publicly accessible, then gets locked down so it can only be reached via a Private Endpoint inside your spoke VNet.
- An Azure Policy guardrail that actively blocks creation of public IP addresses, proving your Infrastructure as Code enforces security posture.
- A complete terraform destroy that removes everything in one command.
- Secret Mission: Add a second spoke VNet with its own NSG rules and peer it to the hub, demonstrating how Zero Trust scales with microsegmentation.
Are there any prerequisites?
You'll need an Azure account with an active subscription, Terraform installed, Azure CLI installed, and VS Code installed. Step 1 will walk you through confirming everything is ready.
Before We Start
Before writing any infrastructure code, let's lock in what you're about to build and which Zero Trust principles drive each piece of the architecture.
Set Up Your Project and Authenticate
Terraform needs a live connection to your Azure subscription before it can plan or deploy anything.
In this step, you will create your project directory, configure the AzureRM provider, authenticate via the Azure CLI, and confirm Terraform can talk to Azure.
In this step, get ready to:
- Create the project directory and Terraform files.
- Configure the AzureRM provider and authenticate to Azure.
- Run terraform plan to verify the connection works.
Create the project directory
Every Terraform project lives in its own directory. You will create a folder on your Desktop and add the four standard Terraform files inside it.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir zero-trust-azure
cd zero-trust-azure
- Create the four Terraform files by running:
touch main.tf variables.tf outputs.tf terraform.tfvars
- Confirm the files exist by running:
ls
You should see main.tf, variables.tf, outputs.tf, and terraform.tfvars listed in the output.
Don't see all four files?
Make sure you ran the touch command from inside the zero-trust-azure folder. If you are still in ~/Desktop, run cd zero-trust-azure first, then re-run the touch command.
help me create the Terraform project files
Configure the AzureRM provider and authenticate
The provider block tells Terraform which cloud platform to talk to and which version of the plugin to use. Pinning to an exact version keeps your builds reproducible.
- Open the zero-trust-azure folder in VS Code by pressing Cmd+Space (macOS) or the Windows key (Windows), typing VS Code, and pressing Enter.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to Desktop, select the zero-trust-azure folder, and click Open.
- In the Explorer sidebar, click main.tf to open it.
- Paste the following provider configuration into main.tf:
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "4.81.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
What does this code do?
- The required_providers block pins the AzureRM provider to version 4.81.0 so every team member uses the same plugin.
- The provider "azurerm" block configures the connection. features {} is required by AzureRM even if empty.
- subscription_id = var.subscription_id reads the subscription from a variable so you never hardcode it.
- Save main.tf by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Before Terraform can download the provider, it needs to authenticate to your Azure subscription.
- Switch back to your terminal and authenticate to Azure by running:
az login
What happens when you run this?
A browser window opens asking you to sign in with your Azure account. After you sign in, the terminal prints a JSON array of your subscriptions. Each subscription has an id field. Copy the id value for the subscription you want to use. You will need it in the next substep.
- Now initialize Terraform to download the AzureRM provider by running:
terraform init
You should see output ending with Terraform has been successfully initialized! confirming the provider downloaded.
Seeing a provider download error?
Make sure you are running the command from inside the zero-trust-azure directory (the one containing main.tf). If az login did not complete, re-run it and finish the browser sign-in before retrying terraform init.
help me fix terraform init failing to download the azurerm provider
✔️ Awesome, I've got everything!
Great. Make sure you saved main.tf and keep your terminal open.
ⓧ I'd like to double check the full code
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "4.81.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
Define your variables and verify the connection
Terraform needs variable definitions before terraform plan can run, because main.tf references var.subscription_id.
- In VS Code, click variables.tf in the Explorer sidebar to open it.
- Paste the following variable definitions into variables.tf:
variable "subscription_id" {
description = "Azure subscription ID"
type = string
}
variable "location" {
description = "Azure region for all resources"
type = string
default = "eastus"
}
variable "storage_account_name" {
description = "Globally unique name for the storage account (3-24 chars, lowercase letters and numbers only)"
type = string
}
What does this code do?
- subscription_id identifies which Azure subscription Terraform deploys into. It has no default so you must supply it.
- location sets the Azure region. It defaults to eastus so you can omit it unless you want a different region.
- storage_account_name must be globally unique across all of Azure. You will pick your own name using lowercase letters and numbers only.
- Save variables.tf.
- Now open terraform.tfvars in the Explorer sidebar.
- Paste the following, replacing the placeholder values with your own:
subscription_id = "[[SUBSCRIPTION_ID="your-subscription-id-here"]]"
location = "eastus"
storage_account_name = "[[STORAGE_NAME="ztstorageXXXXX"]]"
Where do I find these values?
- subscription_id: Copy the id value from the JSON output that az login printed in your terminal.
- storage_account_name: Choose a name like ztstorage followed by a few random digits (e.g. ztstorage82917). It must be 3-24 characters, all lowercase letters and numbers.
- Save terraform.tfvars.
✔️ Awesome, I've got everything!
Great. Make sure both variables.tf and terraform.tfvars are saved.
ⓧ I'd like to double check the full code
variable "subscription_id" {
description = "Azure subscription ID"
type = string
}
variable "location" {
description = "Azure region for all resources"
type = string
default = "eastus"
}
variable "storage_account_name" {
description = "Globally unique name for the storage account (3-24 chars, lowercase letters and numbers only)"
type = string
}
subscription_id = "[[SUBSCRIPTION_ID="your-subscription-id-here"]]"
location = "eastus"
storage_account_name = "[[STORAGE_NAME="ztstorageXXXXX"]]"
Before you run terraform plan, what do you think it will output? There are no resources defined yet, only the provider.
- Verify your authentication and configuration by running:
terraform plan
You should see the message: No changes. Your infrastructure matches the configuration. This confirms Terraform connected to Azure and found nothing to create.
Seeing a variable error?
If you see an error about subscription_id or another variable not being set, check that terraform.tfvars is saved and contains a valid subscription ID string (not the placeholder text).
If you see an authentication error, re-run az login and make sure you complete the browser sign-in.
help me fix terraform plan failing in my zero-trust-azure project
Your project is wired up and Terraform can reach Azure. Next, you will deploy the hub-spoke network that forms the backbone of your Zero Trust architecture.
Deploy the Hub-Spoke Network with Deny-All NSGs
In Step 1, you authenticated Terraform to your Azure subscription. Now it is time to build the network foundation that enforces the "assume breach" principle.
A flat network trusts everything inside it. If an attacker compromises one workload, they can reach every other workload on the same network. A hub-spoke topology with deny-all Network Security Groups eliminates that lateral movement. Every subnet starts locked down, and traffic flows only when you explicitly allow it.
In this step, get ready to:
- Define a hub VNet and a spoke VNet with bidirectional peering.
- Create deny-all NSGs and attach them to subnets.
- Deploy all resources and confirm the deny-all rules in the Azure Portal.
Define the hub and spoke virtual networks
The hub VNet acts as the central trust boundary. The spoke VNet holds workload and private endpoint subnets. Separating them means you control exactly what crosses from one to the other.
- In main.tf, add the following code below the provider "azurerm" block to create the resource group and hub VNet:
# Resource Group
resource "azurerm_resource_group" "zt" {
name = "zt-rg"
location = var.location
}
# --- Hub VNet ---
resource "azurerm_virtual_network" "hub" {
name = "hub-vnet"
address_space = ["10.0.0.0/16"]
location = azurerm_resource_group.zt.location
resource_group_name = azurerm_resource_group.zt.name
}
resource "azurerm_subnet" "hub_default" {
name = "hub-default"
resource_group_name = azurerm_resource_group.zt.name
virtual_network_name = azurerm_virtual_network.hub.name
address_prefixes = ["10.0.1.0/24"]
}
What does this code do?
The resource group zt-rg is the container for all your Zero Trust resources. Everything you create in this project lives here, making cleanup a single delete.
The hub VNet uses the 10.0.0.0/16 address space (65,536 addresses). Its subnet hub-default carves out a /24 block for centralized services.
- Directly below the hub subnet block, add the spoke VNet with two subnets:
# --- Spoke VNet ---
resource "azurerm_virtual_network" "spoke" {
name = "spoke-vnet"
address_space = ["10.1.0.0/16"]
location = azurerm_resource_group.zt.location
resource_group_name = azurerm_resource_group.zt.name
}
resource "azurerm_subnet" "spoke_workload" {
name = "spoke-workload"
resource_group_name = azurerm_resource_group.zt.name
virtual_network_name = azurerm_virtual_network.spoke.name
address_prefixes = ["10.1.0.0/24"]
}
resource "azurerm_subnet" "spoke_pe" {
name = "spoke-private-endpoints"
resource_group_name = azurerm_resource_group.zt.name
virtual_network_name = azurerm_virtual_network.spoke.name
address_prefixes = ["10.1.1.0/24"]
}
Why two subnets in the spoke?
The spoke-workload subnet holds compute resources. The spoke-private-endpoints subnet is dedicated to Private Endpoints. Separating them lets you apply different NSG rules to each.
- Save main.tf.
- Confirm your syntax is valid by running:
terraform plan
You should see Terraform planning to create 5 resources (1 resource group, 2 VNets, 2 subnets in the hub, and 1 additional subnet in the spoke).
Seeing a syntax error?
Check that every opening brace { has a matching closing brace }. Also confirm that address_prefixes uses square brackets (it expects a list).
Help me debug my Terraform plan error for VNets and subnets.
- Now add the bidirectional VNet peering below the spoke subnets in main.tf:
# --- VNet Peering ---
resource "azurerm_virtual_network_peering" "hub_to_spoke" {
name = "hub-to-spoke"
resource_group_name = azurerm_resource_group.zt.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 = "spoke-to-hub"
resource_group_name = azurerm_resource_group.zt.name
virtual_network_name = azurerm_virtual_network.spoke.name
remote_virtual_network_id = azurerm_virtual_network.hub.id
allow_forwarded_traffic = true
}
What does VNet peering do?
Peering connects two VNets so traffic can flow between them over Azure's backbone. You need both directions (hub-to-spoke AND spoke-to-hub) because peering is not automatically reciprocal.
The allow_forwarded_traffic = true setting lets the hub forward traffic on behalf of other networks. This is essential for a hub-spoke design where the hub acts as a routing point.
- Save main.tf.
Create deny-all Network Security Groups
With the network topology in place, you need security rules that enforce "deny by default". An NSG with deny-all rules at the lowest custom priority ensures nothing gets through unless a higher-priority rule explicitly allows it.
- Add the hub NSG below the peering blocks in main.tf:
# --- Network Security Groups ---
resource "azurerm_network_security_group" "hub_nsg" {
name = "hub-nsg"
location = azurerm_resource_group.zt.location
resource_group_name = azurerm_resource_group.zt.name
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 = "DenyAllOutbound"
priority = 4096
direction = "Outbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
Why priority 4096?
NSG rules are evaluated from lowest priority number (highest priority) to highest priority number (lowest priority). By setting deny-all at 4096, any future allow rules you add at a lower number (like 100 or 200) take precedence.
This is the Zero Trust pattern in action. Everything is denied by default. You only punch holes for traffic you explicitly trust.
- Save main.tf and run a quick plan to confirm both the peering and hub NSG are valid:
terraform plan
You should see Terraform planning to create the VNet peering and hub NSG resources in addition to the VNets from before.
Plan shows errors about security_rule?
Make sure each security_rule block is nested inside the azurerm_network_security_group resource block, not outside it. Also confirm priority is set to 4096 (an integer, not a string).
Help me fix my NSG security_rule block in Terraform.
- Add the spoke NSG directly below the hub NSG in main.tf:
resource "azurerm_network_security_group" "spoke_nsg" {
name = "spoke-nsg"
location = azurerm_resource_group.zt.location
resource_group_name = azurerm_resource_group.zt.name
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 = "DenyAllOutbound"
priority = 4096
direction = "Outbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
}
}
What does this code do?
This is identical in structure to the hub NSG. Both the hub and spoke subnets get their own deny-all rules. This means even traffic between subnets in the SAME VNet is blocked unless you add an explicit allow rule later.
- Now associate each NSG with its subnet by adding this below the spoke NSG in main.tf:
resource "azurerm_subnet_network_security_group_association" "hub_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_workload_assoc" {
subnet_id = azurerm_subnet.spoke_workload.id
network_security_group_id = azurerm_network_security_group.spoke_nsg.id
}
What does this code do?
An NSG only takes effect when associated with a subnet or network interface. These associations attach hub-nsg to the hub's default subnet and spoke-nsg to the spoke's workload subnet.
- Save main.tf and validate that all your code is syntactically correct:
terraform validate
You should see the message: Success! The configuration is valid..
Deploy and verify in the Azure Portal
Your network code is complete. Time to add outputs so Terraform reports the key resource IDs, then deploy everything to Azure.
- Replace the empty outputs.tf with these output definitions:
output "resource_group_name" {
value = azurerm_resource_group.zt.name
}
output "hub_vnet_id" {
value = azurerm_virtual_network.hub.id
}
output "spoke_vnet_id" {
value = azurerm_virtual_network.spoke.id
}
What does this code do?
Outputs print key values after terraform apply completes. You will see the resource group name and both VNet IDs, confirming they were created.
- Save outputs.tf.
Before you deploy, what do you expect to happen? Will Terraform create all the resources in one go, or will it fail because of the deny-all rules blocking its own traffic?
- Deploy your entire network infrastructure by running:
terraform apply
- Terraform will show you a plan of all resources to be created. Type yes and press Enter to confirm.
Terraform succeeds because NSG rules control data-plane traffic between VMs and services. Terraform communicates with Azure's control plane (the management API), which is unaffected by NSG rules.
You should see output values printed for resource_group_name, hub_vnet_id, and spoke_vnet_id.
Apply failed with an authorization error?
Your Azure CLI session may have expired. Run az login again to re-authenticate, then retry terraform apply.
Help me troubleshoot my terraform apply authorization failure.
- Open the Azure Portal at portal.azure.com.
- In the search bar at the top, type hub-nsg and select the Network Security Group result.
- Click Inbound security rules in the left sidebar.
You should see your custom DenyAllInbound rule at priority 4096 with Action set to Deny. There are no custom allow rules, confirming this subnet blocks all inbound traffic by default.
✔️ 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
Your main.tf should contain these sections in order (scroll up to confirm each is present):
- The terraform block and provider "azurerm" block (from Step 1).
- Resource Group (azurerm_resource_group "zt").
- Hub VNet and hub-default subnet.
- Spoke VNet with spoke-workload and spoke-private-endpoints subnets.
- VNet Peering (hub_to_spoke and spoke_to_hub).
- Hub NSG with DenyAllInbound and DenyAllOutbound rules.
- Spoke NSG with DenyAllInbound and DenyAllOutbound rules.
- Two subnet-NSG associations (hub_assoc and spoke_workload_assoc).
Your outputs.tf should contain:
output "resource_group_name" {
value = azurerm_resource_group.zt.name
}
output "hub_vnet_id" {
value = azurerm_virtual_network.hub.id
}
output "spoke_vnet_id" {
value = azurerm_virtual_network.spoke.id
}
Your segmented network is live. Every subnet starts in a locked-down state where no traffic flows unless you explicitly allow it. Next up, you will deploy a Storage Account and witness firsthand why network segmentation alone is not enough to protect your data.
Lock Down Storage with a Private Endpoint
In the previous step, you proved that your storage account is wide open to the public internet despite sitting behind a segmented network with deny-all NSGs. That gap violates the Zero Trust principle of "verify explicitly" because anyone can reach the data without passing through your controlled network.
A Private Endpoint fixes this by giving the storage account a private IP address inside your spoke VNet. Once you disable public access, the only path to data goes through your network. Now your NSGs actually protect the data.
In this step, get ready to:
- Add a Private Endpoint and Private DNS Zone for the storage account.
- Disable public network access on the storage account.
- Verify that public access now fails while the private endpoint is active.
Add the Private Endpoint resource
A Private Endpoint creates a network interface with a private IP in your subnet, connected directly to the storage account's blob service. Clients inside the VNet reach storage through this private IP instead of the public internet.
- In main.tf, add the following code below the azurerm_storage_container resource to create the Private Endpoint:
# --- Private Endpoint for Storage ---
resource "azurerm_private_endpoint" "storage" {
name = "pe-storage-blob"
location = azurerm_resource_group.zt.location
resource_group_name = azurerm_resource_group.zt.name
subnet_id = azurerm_subnet.spoke_pe.id
private_service_connection {
name = "psc-storage-blob"
private_connection_resource_id = azurerm_storage_account.zt.id
subresource_names = ["blob"]
is_manual_connection = false
}
private_dns_zone_group {
name = "storage-dns-zone-group"
private_dns_zone_ids = [azurerm_private_dns_zone.blob.id]
}
}
What does this code do?
- subnet_id places the endpoint's network interface in the spoke-private-endpoints subnet (10.1.1.0/24).
- private_service_connection links the endpoint to the storage account's blob subresource. The is_manual_connection = false means Azure auto-approves the connection.
- private_dns_zone_group registers the endpoint's private IP in a DNS zone so that DNS lookups for the storage account resolve to the private IP inside the VNet.
Add the Private DNS Zone and VNet link
Without a Private DNS Zone, clients inside your VNet would still resolve the storage account's hostname to its public IP. The DNS zone overrides that resolution so traffic stays private.
- Still in main.tf, add this code directly below the azurerm_private_endpoint resource:
resource "azurerm_private_dns_zone" "blob" {
name = "privatelink.blob.core.windows.net"
resource_group_name = azurerm_resource_group.zt.name
}
resource "azurerm_private_dns_zone_virtual_network_link" "blob_link" {
name = "blob-vnet-link"
resource_group_name = azurerm_resource_group.zt.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 DNS zone name privatelink.blob.core.windows.net is Azure's documented zone name for blob storage Private Endpoints.
- The VNet link connects this DNS zone to your spoke VNet so that any resource in the spoke resolves the storage hostname to the private IP.
- Save main.tf.
Disable public access and deploy
Now that the private path exists, you can shut the public door. Changing one boolean on the storage account eliminates all public internet access.
- In main.tf, find the azurerm_storage_account resource and locate this line:
public_network_access_enabled = true
- Change true to false, so the line now looks like this:
public_network_access_enabled = false
- Next, open outputs.tf and add this output at the bottom of the file to expose the private endpoint's IP address:
output "private_endpoint_ip" {
value = azurerm_private_endpoint.storage.private_service_connection[0].private_ip_address
}
Why expose the private IP as an output?
This output lets you confirm which private IP Terraform assigned to the endpoint after deployment. You can compare it against what the Azure Portal shows to verify the DNS zone is working correctly.
- Save both main.tf and outputs.tf.
- Deploy the changes by running:
terraform apply
- When prompted, type yes and press Enter.
Terraform creates the Private Endpoint, DNS zone, and VNet link, then updates the storage account to disable public access. Look for the private_endpoint_ip in the outputs. It should be an address in the 10.1.1.x range.
Seeing an error about the DNS zone?
- Make sure the azurerm_private_dns_zone block appears before the azurerm_private_dns_zone_virtual_network_link block in your file. Terraform resolves dependencies by reference, but ordering helps readability and avoids confusion.
- Confirm the zone name is exactly privatelink.blob.core.windows.net with no typos.
- help me fix a Terraform error with my Private DNS Zone
Verify that public access is blocked
Time to prove the lockdown works. You will run the same blob list command from the previous step, but this time it should fail because your local machine is outside the VNet.
Before you run the command, predict: will it succeed or fail? Why?
- Run the same blob list command from your local terminal:
az storage blob list --account-name [[STORAGE_ACCOUNT_NAME="your storage account name e.g. ztstorageXXXXX"]] --container-name zt-data --auth-mode login
The command fails with a network or authorization error. Your local machine is outside the spoke VNet, and public access is disabled. There is no path from the public internet to this storage account anymore.
Why does this prove Zero Trust is working?
In the previous step, this exact command succeeded because the storage account accepted public internet traffic. Now that public_network_access_enabled = false is set, the only way to reach blob data is through the Private Endpoint inside your spoke VNet.
Your NSGs, VNet segmentation, and Private Endpoint work together. The network controls actually mean something because there is no backdoor through the public internet.
- Open the Azure Portal and navigate to your resource group zt-rg.
- Click on the Private Endpoint resource pe-storage-blob.
- Confirm the Private IP shown is in the 10.1.1.x range (your spoke-private-endpoints subnet).
Command still succeeding?
- Verify you changed public_network_access_enabled to false (not just added the Private Endpoint). Both changes are required.
- Run terraform apply again to make sure the change was applied. Check the plan output mentions updating the storage account.
- Azure may take 1-2 minutes to propagate the network restriction. Wait a moment and try again.
- my az storage blob list command still works after disabling public access
✔️ 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
Your main.tf is long, so confirm these sections exist in order:
- Provider block and resource group.
- Hub VNet and subnet.
- Spoke VNet with spoke_workload and spoke_pe subnets.
- VNet peering (hub-to-spoke and spoke-to-hub).
- Two NSGs (hub-nsg, spoke-nsg) with deny-all rules and subnet associations.
- Storage account with public_network_access_enabled = false and the zt-data container.
- Private Endpoint (pe-storage-blob) targeting the blob subresource in spoke_pe subnet.
- Private DNS Zone (privatelink.blob.core.windows.net) with a VNet link to the spoke.
Your outputs.tf should contain:
output "resource_group_name" {
value = azurerm_resource_group.zt.name
}
output "hub_vnet_id" {
value = azurerm_virtual_network.hub.id
}
output "spoke_vnet_id" {
value = azurerm_virtual_network.spoke.id
}
output "storage_account_name" {
value = azurerm_storage_account.zt.name
}
output "private_endpoint_ip" {
value = azurerm_private_endpoint.storage.private_service_connection[0].private_ip_address
}
Your storage account is now locked down. The only access path runs through the Private Endpoint inside your spoke VNet. Next up, you will add an Azure Policy guardrail that prevents anyone from creating public IPs in your resource group.
Enforce Zero Trust with Azure Policy
Your network is segmented and your storage is locked behind a Private Endpoint. But infrastructure drifts.
Someone on your team could manually attach a public IP to a network interface and bypass every control you just built. Azure Policy acts as a guardrail at the Azure control plane level, blocking insecure configurations before they ever reach your network. In this step, you will assign a built-in policy that prevents public IPs from being attached to any network interface in your resource group.
In this step, get ready to:
- Add an Azure Policy assignment to your Terraform configuration.
- Deploy the policy and prove it blocks insecure resource creation.
Add the Azure Policy assignment
Azure has a built-in policy called "Network interfaces should not have public IPs" that actively denies any attempt to attach a public IP to a NIC. By assigning it to your resource group via Terraform, the guardrail is version-controlled and reproducible.
- In main.tf, scroll to the bottom of the file (after the Private DNS Zone VNet link resource).
- Add the Azure Policy assignment resource by pasting the following code at the end of the file:
# --- Azure Policy Assignment ---
resource "azurerm_resource_group_policy_assignment" "deny_public_ip" {
name = "deny-public-ip-on-nic"
resource_group_id = azurerm_resource_group.zt.id
policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/83a86a26-fd1f-447c-b59d-e51f44264114"
display_name = "Deny Public IPs on Network Interfaces"
description = "Zero Trust: Prevent network interfaces from having public IPs attached."
enforce = true
}
What does this code do?
This assigns a built-in Azure Policy to your zt-rg resource group. The policy_definition_id points to Microsoft's built-in "Network interfaces should not have public IPs" policy.
Setting enforce = true means Azure will actively deny any request that violates this policy. Anyone trying to attach a public IP to a NIC in your resource group will get blocked at the control plane, before the resource is ever created.
- Save main.tf.
- Now open outputs.tf and add this output at the end of the file:
output "policy_assignment_id" {
value = azurerm_resource_group_policy_assignment.deny_public_ip.id
}
- Save outputs.tf.
Seeing a syntax error?
- Make sure the policy assignment block is placed after the closing } of the azurerm_private_dns_zone_virtual_network_link resource, not nested inside it.
- Check that the policy_definition_id string starts with /providers/ and contains the full GUID.
- Help me debug a Terraform syntax error in my policy assignment resource.
✔️ Awesome, I've got everything!
Double check you have saved both main.tf and outputs.tf.
ⓧ I'd like to double check the full code
Your outputs.tf should now contain all five outputs:
output "resource_group_name" {
value = azurerm_resource_group.zt.name
}
output "hub_vnet_id" {
value = azurerm_virtual_network.hub.id
}
output "spoke_vnet_id" {
value = azurerm_virtual_network.spoke.id
}
output "storage_account_name" {
value = azurerm_storage_account.zt.name
}
output "private_endpoint_ip" {
value = azurerm_private_endpoint.storage.private_service_connection[0].private_ip_address
}
output "policy_assignment_id" {
value = azurerm_resource_group_policy_assignment.deny_public_ip.id
}
For main.tf, scroll to the very bottom. The last resource should be the azurerm_resource_group_policy_assignment block with name = "deny-public-ip-on-nic" and enforce = true. Confirm it is present and placed after all the Private DNS Zone resources.
Deploy the policy and test the guardrail
- Deploy the policy assignment by running:
terraform apply -auto-approve
Terraform creates the policy assignment resource. You should see Apply complete! Resources: 1 added in your output.
Why a built-in policy instead of a custom one?
Azure provides hundreds of built-in policy definitions maintained by Microsoft. Using a built-in definition means you get automatic updates and do not need to author policy JSON yourself. This keeps the project focused on architecture and IaC, not policy authoring.
Now for the real test. You are going to attempt an action that violates your policy and see Azure reject it in real time.
Before you run the next command, what do you think will happen when you try to create a public IP address in your resource group? Will Azure let you, or block you?
- Attempt to create a public IP in your resource group by running:
az network public-ip create --resource-group zt-rg --name test-pip --sku Standard
The command fails with a RequestDisallowedByPolicy error. Your policy actively blocked the creation of a public IP address at the Azure control plane. This is the "verify explicitly" principle in action: even a human with permissions to create resources cannot bypass your Zero Trust guardrail.
Command succeeded instead of failing?
- Azure Policy assignments can take up to 5 minutes to become effective. Wait a few minutes and try the command again.
- Confirm the policy deployed by running terraform state list and checking that azurerm_resource_group_policy_assignment.deny_public_ip appears in the list.
- Make sure enforce = true is set in your resource block. If it is missing, Terraform defaults to true, but double check the attribute is present and correct.
- Help me figure out why my Azure Policy assignment is not blocking public IP creation.
Your Zero Trust network is complete. You have segmented networks with deny-all NSGs, private-only storage access via a Private Endpoint, and an Azure Policy guardrail that prevents drift. Every layer enforces the principle that nothing is trusted by default. Next up, you can scale this pattern by adding a second spoke in the Secret Mission.
Secret mission
Add a Second Spoke with Microsegmentation
Real Zero Trust environments have many network segments. Can you scale the pattern by adding a second spoke VNet with its own isolated NSG rules and peering it to the hub, without disrupting the first spoke?
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 Private Endpoint is the only resource with an ongoing cost.
Cost warning
Private Endpoints cost approximately $0.01/hour (~$0.24/day). All other resources in this project (VNets, NSGs, Policy assignments) are free. If you leave resources running overnight or over a weekend, the Private Endpoint will accumulate a small charge.
Run terraform destroy to remove everything and stop all charges immediately.
Resources you used:
- 1 Resource Group (zt-rg).
- Virtual Networks (hub-vnet, spoke-vnet) with VNet peering.
- Network Security Groups (hub-nsg, spoke-nsg).
- 1 Storage Account with a Private Endpoint and Private DNS Zone.
- 1 Azure Policy Assignment.
- Local project directory zero-trust-azure/ on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively experimenting with your Zero Trust network or plan to extend it further.
- Your Private Endpoint will continue to cost ~$0.01/hour (~$0.24/day).
- All other resources (VNets, NSGs, Policy) are free and will not generate charges.
- Your Terraform state file (terraform.tfstate) tracks everything that is deployed.
Pause - I'll come back to this later
There is no built-in pause for Azure networking resources. Your best option is to destroy everything now and redeploy later using your Terraform code.
- Keep your local zero-trust-azure/ directory intact. It contains all the code you need to redeploy.
- Run terraform destroy to remove all Azure resources and stop charges.
- When you are ready to resume, run terraform apply from the same directory to recreate everything.
Delete - I don't want to use this again
Remove all project resources and local files completely.
- Open your terminal and navigate to your project directory by running:
cd ~/Desktop/zero-trust-azure
- Destroy all Azure resources by running:
terraform destroy
- Terraform will display a plan of all resources it will remove. Type yes when prompted and press Enter to confirm.
- Wait for the destroy to complete. Terraform removes all resources in reverse dependency order.
- Once the destroy finishes, delete the local project folder by running:
cd ~/Desktop
rm -rf zero-trust-azure
Windows users
If you are on Windows, use this command instead to remove the project folder:
cd ~\Desktop
rmdir /s /q zero-trust-azure
Nice Work!
Nice Work!
You just built a fully codified Zero Trust network environment on Azure using Terraform. Every access path is explicitly granted, every network segment is isolated by default, and the entire architecture lives in version-controllable code.
Here's what you learned:
- Deployed a hub-spoke network topology enforcing microsegmentation via deny-all Network Security Groups.
- Experienced the public access problem firsthand with Azure Storage, then eliminated it with a Private Endpoint.
- Enforced Zero Trust at the Azure control plane using Azure Policy to block public IP creation.
- Wrote all infrastructure as Terraform code that is reproducible, version-controllable, and auditable.
- Secret Mission: Scaled the architecture by adding a second spoke with controlled cross-spoke access rules.
Ready to quiz yourself?