Azure Networking with Terraform
Deploy a production-style Azure VNet, subnets, and NSGs using Terraform.
Introduction
30 Second Summary
Every application you deploy to the cloud needs a network to live in. Before you can run containers, serve APIs, or store data, you need the foundational layer that defines where traffic flows and who can talk to whom.
In this project, you will build a production-style Azure network foundation using Terraform. You will deploy a Virtual Network with purpose-built subnets, add Network Security Groups to control traffic, and store your infrastructure state remotely in Azure Storage.
What You'll Build
By the end, you will run terraform plan and see "No changes" confirming your entire network is exactly as defined in code. You will query your subnets and security rules with the Azure CLI and see real infrastructure responding in your subscription.
By the end of this project, you'll have:
- A Virtual Network with CIDR-planned subnets you can point to and explain to another engineer, ready to host AKS workloads and supporting services.
- Network Security Groups actively filtering traffic on each subnet, verified through CLI queries that show exactly which rules are enforced.
- Terraform remote state locked in Azure Blob Storage, so your infrastructure is safe from local file loss and ready for team collaboration.
- Secret Mission: Refactor your networking into a reusable Terraform module and deploy a second environment (prod) using different variables against the same module code.
Are there any prerequisites?
This project assumes you have an active Azure Pay-As-You-Go subscription, WSL with Ubuntu installed on Windows, and Terraform v1.15.8 already installed inside WSL. Basic familiarity with Terraform syntax and AWS networking concepts (VPCs and subnets) will help you follow the Azure comparisons.
Before We Start
Before you touch any infrastructure, you need to articulate what you are building and why. This is how senior engineers operate: requirements before implementation.
Set Up Azure CLI and Authenticate
Terraform authenticates through your local Azure CLI session when running in a development environment. Without the CLI installed and authenticated, Terraform cannot create or manage any Azure resources on your behalf.
In this step, you will install the Azure CLI inside WSL, authenticate to your Azure subscription, and create the storage infrastructure that will hold your Terraform state file.
In this step, get ready to:
- Install Azure CLI in WSL and confirm the version.
- Authenticate to your Azure subscription and set your target subscription.
- Create a storage account and blob container for Terraform remote state.
Install Azure CLI in WSL and verify the installation
The Azure CLI is a command-line tool that lets you create, query, and delete Azure resources directly from your terminal. Terraform uses the CLI's authentication session to connect to your subscription.
- Install Azure CLI in your WSL terminal by running this command:
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
What does this command do?
This downloads and runs Microsoft's official installer script for Debian-based Linux distributions (which Ubuntu is). It adds the Microsoft package repository to your system and installs the azure-cli package.
The installation takes a minute or two. Once it finishes, you will see output confirming the packages were installed.
- Verify Azure CLI installed correctly by running:
az version
Check the output and find the tab below that matches what you see.
✔️ I see version 2.89.0 or higher
Your Azure CLI is ready to go. Move on to the next substep.
ⓧ I see an older version
You have an older Azure CLI installed. Upgrade it by running:
sudo apt-get update && sudo apt-get install --only-upgrade azure-cli
After the upgrade completes, run az version again to confirm you see 2.89.0 or higher.
ⓧ Command not found
The installation did not complete successfully. Close your terminal and reopen it so PATH changes take effect, then try az version again.
If it still fails, re-run the install command:
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
If you continue to have issues installing or running on WSL, consider installing the CLI for Windows using:
winget install -e --id Microsoft.AzureCLI
Then run az version in a new terminal to confirm it works.
How does this compare to AWS?
The az CLI is the Azure equivalent of the aws CLI. Same concept (programmatic access to cloud resources), different commands and authentication flow.
Authenticate and verify your subscription
The CLI is installed, but it does not know which Azure account to connect to yet. You need to authenticate so Azure trusts commands from this terminal.
- Start the authentication flow by running:
az login
What happens when you run this?
This command opens your default web browser to a Microsoft sign-in page. Sign in with the same Microsoft account that owns your Pay-As-You-Go subscription.
After you sign in, the browser confirms success and you can close the tab. Your terminal displays a JSON list of all subscriptions linked to your account.
Browser not opening?
If WSL cannot open a browser (common in some setups), use the device code flow instead:
az login --use-device-code
This prints a URL and a code. Open the URL in any browser, enter the code, and sign in there.
Help me troubleshoot az login in WSL
- Confirm your subscription details by running:
az account show --output table
You should see a table with your subscription name, subscription ID, and tenant ID. Confirm the subscription name shows your Pay-As-You-Go subscription.
- If you have multiple subscriptions, list them all by running:
az account list --output table
- Set your target subscription explicitly by running this command (replace the placeholder with your actual subscription ID from the table output above):
az account set --subscription [[SUBSCRIPTION_ID="your-subscription-id-from-the-table-above"]]
Why set the subscription explicitly?
If your account has multiple subscriptions, Azure CLI defaults to whichever one was last active. Setting it explicitly ensures every command in this session targets the correct subscription. Terraform will inherit this same subscription context.
- Note down your subscription ID and tenant ID from the table output. You will need both when configuring Terraform in the next step.
Not seeing your Pay-As-You-Go subscription?
Make sure you signed in with the Microsoft account that owns the subscription. If you used a different email, run az logout and then az login again with the correct account.
Help me find my Pay-As-You-Go subscription
Create the storage account for Terraform remote state
Terraform needs a place to store its state file so multiple engineers can collaborate without conflicts. Azure Blob Storage provides built-in lease-based locking, which means you do not need a separate lock table (unlike AWS where you need S3 plus DynamoDB).
- Create a resource group to hold the state storage by running:
az group create --name rg-terraform-state --location eastus --tags environment=shared purpose=terraform-state
What does this command do?
A resource group is a logical container that holds related Azure resources. You are creating one called rg-terraform-state specifically for your Terraform state infrastructure, separate from the networking resources you will build later.
The tags environment=shared and purpose=terraform-state help you identify what this resource group is for at a glance.
You should see JSON output confirming the resource group was created with "provisioningState": "Succeeded".
- Create the storage account by running:
az storage account create --name stcloudplatformtfstate --resource-group rg-terraform-state --location eastus --sku Standard_LRS --encryption-services blob --tags environment=shared purpose=terraform-state
What does this command do?
This creates a storage account named stcloudplatformtfstate with locally redundant storage (Standard_LRS). The name must be globally unique across all of Azure, between 3 and 24 characters, and contain only lowercase letters and numbers.
Standard_LRS stores three copies of your data within a single datacenter. In production, you would use Standard_GRS (geo-redundant) for disaster recovery of state files. LRS is sufficient and cheaper for a learning environment.
Seeing a name already taken error?
Storage account names are globally unique across all of Azure. If stcloudplatformtfstate is already taken, add your initials or a short number to the end (e.g. stcloudplatformtfstate01). Keep it under 24 characters, lowercase letters and numbers only.
If you change this name, remember to use your new name in every command below and in your Terraform backend configuration in the next step.
Help me choose a valid storage account name
This command takes about 30 seconds. When it finishes, you will see JSON output confirming the account was created.
- Create the blob container inside the storage account by running:
az storage container create --name tfstate --account-name stcloudplatformtfstate
You should see "created": true in the output. This container named tfstate is where Terraform will store and lock your state file.
What is a blob container?
A blob container is a folder-like structure inside an Azure Storage account. Think of the storage account as a hard drive and the container as a folder on that drive. Your Terraform state file will live as a blob (file) inside this container.
How much does this cost?
Standard_LRS storage costs approximately $0.02/GB/month. Your Terraform state file will be a few kilobytes. This resource generates ongoing charges, but they are negligible (less than $0.01/month for this project).
Before you move on, predict what you will see if you list the contents of your new blob container. Is there anything in it yet?
- Verify the container exists and is empty by running:
az storage blob list --container-name tfstate --account-name stcloudplatformtfstate --output table
The output is empty (no blobs listed) because you have not stored any state yet. Terraform will write the state file here when you run terraform init in the next step.
Seeing a permissions error?
Make sure you are still authenticated. Run az account show to confirm your session is active. If your session expired, run az login again.
Also confirm you typed the storage account name exactly as you created it. A typo in the account name causes authentication to fail because the account does not exist under your subscription.
Help me fix this permissions error
Your Azure CLI is authenticated and your remote state infrastructure is ready. Next up, you will scaffold the Terraform project and connect it to this storage backend.
Scaffold the Terraform Project and Initialize Remote Backend
Your Azure CLI is authenticated and your state storage is ready. Now it's time to build the Terraform project that will manage all your infrastructure as code.
A professional Terraform structure separates environments, pins provider versions, and stores state remotely so multiple engineers can collaborate without conflicts. Without remote state, your local terraform.tfstate file is a single point of failure. If your machine dies, your infrastructure becomes untracked.
In this step, get ready to:
- Create a professional project directory structure for your dev environment.
- Pin the AzureRM provider and configure the remote backend.
- Initialize Terraform and verify the backend connection to Azure Storage.
Create the project directory structure
The directory layout mirrors how real Azure Cloud Engineer teams organize Terraform. Each environment (dev, prod) gets its own directory with independent state, so changes in dev never accidentally touch production.
- Create the project directory structure and navigate into the dev environment by running these commands:
mkdir -p ~/azure-cloud-platform/terraform/environments/dev
cd ~/azure-cloud-platform/terraform/environments/dev
- Create the Terraform configuration files by running:
touch providers.tf backend.tf variables.tf main.tf outputs.tf terraform.tfvars
- Confirm the files exist by running:
ls
You should see backend.tf, main.tf, outputs.tf, providers.tf, terraform.tfvars, and variables.tf listed in the output.
What is this directory structure?
The environments/dev/ directory isolates your dev infrastructure configuration. In a team setting, you would also have prod/ and staging/ directories at the same level, plus a modules/ directory for reusable code.
Each file has a specific role: providers.tf pins versions, backend.tf configures remote state, variables.tf declares inputs, and main.tf holds the actual resource definitions.
- Open the project in VS Code by running:
code .
VS Code not opening from WSL?
If code . returns "command not found", open VS Code on Windows first, then install the "WSL" extension from the Extensions sidebar. After that, the code command becomes available in your WSL terminal.
help me fix VS Code not opening from my WSL terminal
Write the provider and backend configuration
Version pinning is non-negotiable in professional environments. Without a pin, terraform init grabs the latest provider which may contain breaking changes that silently alter your infrastructure.
- In VS Code, open providers.tf from the Explorer sidebar and paste the following configuration:
terraform {
required_version = ">= 1.9.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "= 5.0.1"
}
}
}
provider "azurerm" {
resource_provider_registrations = "core"
features {}
}
What does this configuration do?
- required_version = ">= 1.9.0" ensures anyone running this project has a compatible Terraform version.
- version = "= 5.0.1" pins the AzureRM provider to exactly 5.0.1. The = prefix means "this exact version, no upgrades."
- resource_provider_registrations = "core" auto-registers essential Azure resource providers (Compute, Networking, Storage) so you don't have to register them manually.
- features {} is a required block for the AzureRM provider, even when empty.
- Now open backend.tf and paste the following configuration:
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "stcloudplatformtfstate"
container_name = "tfstate"
key = "dev.terraform.tfstate"
}
}
What does this backend do?
This tells Terraform to store its state file in the Azure Storage account you created in Step 1, instead of on your local machine. The key field names the blob inside the container, so each environment gets its own state file.
Azure Blob Storage provides built-in lease-based locking for state files. If two engineers run terraform apply at the same time, one will wait until the other finishes. No separate lock table needed.
How does this compare to AWS?
The backend "azurerm" block is equivalent to backend "s3" with a DynamoDB lock table. The difference is that Azure Storage handles both storage and locking in one service, while AWS requires two (S3 + DynamoDB).
- Save both providers.tf and backend.tf.
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on to initialization.
ⓧ I'd like to double check the full code
Here is the complete providers.tf:
terraform {
required_version = ">= 1.9.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "= 5.0.1"
}
}
}
provider "azurerm" {
resource_provider_registrations = "core"
features {}
}
And the complete backend.tf:
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "stcloudplatformtfstate"
container_name = "tfstate"
key = "dev.terraform.tfstate"
}
}
Initialize Terraform and verify the backend connection
Initialization downloads the pinned provider and connects to your remote backend. This is the moment you find out if your configuration is valid.
Before you run the next command, what do you think will happen? Will Terraform connect to your storage account successfully, or will it error out?
- Switch back to your WSL terminal and make sure you are in the ~/azure-cloud-platform/terraform/environments/dev/ directory.
- Initialize Terraform by running:
terraform init
You should see two key messages in the output. First: Successfully configured the backend "azurerm"! confirming the connection to your storage account. Then: Terraform has been successfully initialized!
- Verify the AzureRM provider is locked to the correct version by running:
terraform providers
You should see hashicorp/azurerm listed with version 5.0.1 in the output.
Seeing an error during init?
- If you see an error about the storage account not being found, double check that storage_account_name in backend.tf matches the exact name you created in Step 1 (stcloudplatformtfstate).
- If you see a resource provider registration error, verify Microsoft.Storage is registered by running: az provider show --namespace Microsoft.Storage --query "registrationState"
- If you see an authentication error, make sure your Azure CLI session is still active. Run az account show to check. If it has expired, run az login again.
help me fix my terraform init error with the azurerm backend
Your Terraform project is structured and connected to Azure. Next up, you'll define your first real infrastructure: a Virtual Network with subnets that will host your entire cloud platform.
Deploy Your Virtual Network and Subnets
Your Terraform backend is initialized and pointing to Azure Storage. Now you need the actual network infrastructure that everything else in your platform will plug into.
The Virtual Network is the network boundary for all your Azure resources. Without it, nothing can communicate. You are building the equivalent of an AWS VPC, but Azure VNets have important structural differences.
In this step, get ready to:
- Define your Terraform variables and naming convention for Azure resources.
- Deploy a VNet with CIDR-planned subnets for AKS and supporting services.
- Verify the deployed network using both Terraform and the Azure CLI.
Define variables and the resource group
Azure Cloud Adoption Framework recommends a naming convention of {resource-type}-{workload}-{environment}-{region}. You will encode this into Terraform variables so every resource follows it automatically.
- In VS Code, open variables.tf inside your terraform/environments/dev/ folder and add these base variables:
variable "location" {
description = "Azure region for all resources"
type = string
default = "eastus"
}
variable "environment" {
description = "Environment name (dev, prod)"
type = string
default = "dev"
}
variable "project_name" {
description = "Project name used in resource naming"
type = string
default = "cloudplatform"
}
- Below those, add the network-specific variables in the same file:
variable "vnet_address_space" {
description = "Address space for the Virtual Network"
type = list(string)
default = ["10.0.0.0/16"]
}
variable "aks_subnet_prefix" {
description = "Address prefix for the AKS subnet"
type = string
default = "10.0.1.0/24"
}
variable "services_subnet_prefix" {
description = "Address prefix for the services subnet"
type = string
default = "10.0.2.0/24"
}
What do these variables control?
The first three (location, environment, project_name) drive the naming convention for every resource. They combine into a suffix like cloudplatform-dev-eastus.
The network variables define the CIDR address space. A /16 VNet gives you 65,536 addresses, and each /24 subnet provides 251 usable IPs (Azure reserves 5 per subnet). This leaves room for additional subnets in future projects.
- Save variables.tf.
- Now open terraform.tfvars in the same folder and set the values explicitly:
location = "eastus"
environment = "dev"
project_name = "cloudplatform"
vnet_address_space = ["10.0.0.0/16"]
aks_subnet_prefix = "10.0.1.0/24"
services_subnet_prefix = "10.0.2.0/24"
Why both defaults and a tfvars file?
Defaults in variables.tf document what a reasonable value looks like. The terraform.tfvars file is what Terraform actually reads at runtime. In a team setting, each environment (dev, prod) has its own tfvars file with environment-specific values.
- Save terraform.tfvars.
- Confirm your syntax is valid by running this in your WSL terminal from the terraform/environments/dev/ directory:
terraform validate
You should see: Success! The configuration is valid.
Seeing a syntax error?
- Check that every variable block in variables.tf has matching opening and closing braces.
- Ensure terraform.tfvars uses = (not :) between key and value.
- Make sure you are running the command from ~/azure-cloud-platform/terraform/environments/dev/.
Ask for help: Why is terraform validate failing on my variables?
Now you will define the resource group that contains your network resources.
- Open main.tf and add the locals block and resource group:
locals {
resource_suffix = "${var.project_name}-${var.environment}-${var.location}"
common_tags = {
environment = var.environment
project = var.project_name
managed-by = "terraform"
}
}
resource "azurerm_resource_group" "main" {
name = "rg-${local.resource_suffix}"
location = var.location
tags = local.common_tags
}
What does this code do?
- The locals block builds a reusable suffix (cloudplatform-dev-eastus) so every resource name follows the Azure naming convention without repetition.
- The common_tags map ensures all resources are tagged with environment, project, and the fact they are managed by Terraform. Tags are how teams track ownership and costs.
- The azurerm_resource_group creates a container named rg-cloudplatform-dev-eastus. Every Azure resource must belong to a resource group.
- Save main.tf.
- Preview what Terraform will create by running:
terraform plan
You should see Plan: 1 to add, 0 to change, 0 to destroy. The one resource is your resource group.
Seeing errors about resource provider registration?
If you see an error mentioning provider registration, confirm Microsoft.Storage is registered on your subscription by running:
az provider show --namespace Microsoft.Storage --query "registrationState"
You should see "Registered". If not, the resource_provider_registrations = "core" setting in your providers.tf should handle this automatically on the next apply.
Ask for help: Why is Terraform failing with a provider registration error?
Add the VNet and subnets
With the resource group planned, you can now define the network resources that live inside it.
- In main.tf, add the VNet and both subnets below the resource group you just wrote:
resource "azurerm_virtual_network" "main" {
name = "vnet-${local.resource_suffix}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
address_space = var.vnet_address_space
tags = local.common_tags
}
resource "azurerm_subnet" "aks" {
name = "snet-aks-${var.environment}-${var.location}"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [var.aks_subnet_prefix]
}
resource "azurerm_subnet" "services" {
name = "snet-services-${var.environment}-${var.location}"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [var.services_subnet_prefix]
}
What does this code do?
- The azurerm_virtual_network creates a VNet named vnet-cloudplatform-dev-eastus with address space 10.0.0.0/16.
- Each subnet is a separate azurerm_subnet resource (not inline). The AzureRM provider docs explicitly warn that inline subnets conflict with standalone subnet resources. Professional Terraform always uses separate resources.
- The AKS subnet (10.0.1.0/24) will host future Kubernetes nodes. The services subnet (10.0.2.0/24) will host supporting services like databases and caches.
Compare to AWS: Azure subnets span the entire VNet region (no Availability Zone concept at subnet level). Azure reserves 5 IPs per subnet versus AWS reserving 5.
- Save main.tf.
- Now open outputs.tf and define the outputs that expose your network resource IDs:
output "resource_group_name" {
description = "Name of the resource group"
value = azurerm_resource_group.main.name
}
output "vnet_id" {
description = "ID of the Virtual Network"
value = azurerm_virtual_network.main.id
}
output "vnet_name" {
description = "Name of the Virtual Network"
value = azurerm_virtual_network.main.name
}
output "aks_subnet_id" {
description = "ID of the AKS subnet"
value = azurerm_subnet.aks.id
}
output "services_subnet_id" {
description = "ID of the services subnet"
value = azurerm_subnet.services.id
}
Why define outputs?
Outputs expose values like subnet IDs so other Terraform configurations (or future modules) can reference them. When you add AKS in a later project, it will need the aks_subnet_id to know where to place its nodes.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved all four files: variables.tf, terraform.tfvars, main.tf, and outputs.tf.
ⓧ I'd like to double check the full code
Here is terraform.tfvars:
location = "eastus"
environment = "dev"
project_name = "cloudplatform"
vnet_address_space = ["10.0.0.0/16"]
aks_subnet_prefix = "10.0.1.0/24"
services_subnet_prefix = "10.0.2.0/24"
Here is outputs.tf:
output "resource_group_name" {
description = "Name of the resource group"
value = azurerm_resource_group.main.name
}
output "vnet_id" {
description = "ID of the Virtual Network"
value = azurerm_virtual_network.main.id
}
output "vnet_name" {
description = "Name of the Virtual Network"
value = azurerm_virtual_network.main.name
}
output "aks_subnet_id" {
description = "ID of the AKS subnet"
value = azurerm_subnet.aks.id
}
output "services_subnet_id" {
description = "ID of the services subnet"
value = azurerm_subnet.services.id
}
For variables.tf, scroll up and confirm both variable blocks are present in order: the base variables (location, environment, project_name) followed by the network variables (vnet_address_space, aks_subnet_prefix, services_subnet_prefix).
For main.tf, confirm you have the locals block, then the resource group, then the VNet, then both subnets, in that order.
Apply and verify the deployment
Everything is defined. Before you apply, think about what you expect Terraform to create.
- Run the plan to see the full set of resources:
terraform plan
You should see Plan: 4 to add, 0 to change, 0 to destroy. The four resources are: 1 resource group, 1 VNet, and 2 subnets.
- Before you run apply, predict: will all four resources succeed on the first attempt, or will any fail?
- Deploy the infrastructure by running:
terraform apply
- When prompted, type yes and press Enter.
Terraform creates all four resources and prints the outputs you defined. You should see your VNet ID, subnet IDs, and resource group name in the output.
Apply failed with an authorization error?
- Make sure your Azure CLI session is still active. Run az account show to check. If it returns an error, run az login again.
- Confirm your subscription has the Contributor role or equivalent permissions to create resource groups and network resources.
Ask for help: Why is terraform apply failing with an authorization error on my Azure subscription?
Now verify the deployment using the Azure CLI directly.
- Check your subnets by running:
az network vnet subnet list --resource-group rg-cloudplatform-dev-eastus --vnet-name vnet-cloudplatform-dev-eastus --output table
You should see both subnets listed: snet-aks-dev-eastus with prefix 10.0.1.0/24 and snet-services-dev-eastus with prefix 10.0.2.0/24.
- Confirm the remote state was updated by running:
az storage blob list --container-name tfstate --account-name stcloudplatformtfstate --output table
You should see dev.terraform.tfstate listed in the blob container. Your state is safely stored in Azure, not on your local machine.
Why verify with both Terraform and Azure CLI?
Terraform tells you what it thinks it deployed. The Azure CLI tells you what actually exists in Azure. Checking both catches drift and confirms your configuration matches reality. This is a habit that prevents surprises in production.
Your network foundation is live. You have a VNet with two purpose-built subnets ready for workloads. Next up, you will add Network Security Groups to control which traffic can reach each subnet.
Add Network Security Groups
Your VNet and subnets are deployed, but right now there is nothing controlling what traffic can flow in or out. Without explicit filtering, any resource you place in those subnets is reachable from the public internet.
Network Security Groups are Azure's built-in packet filter. They define allow and deny rules that decide which traffic reaches your subnets. In this step, you will create NSGs for both subnets and discover an important gap between creating an NSG and actually protecting a subnet.
In this step, get ready to:
- Create an NSG for the AKS subnet with HTTPS, VNet, and deny-all rules.
- Create an NSG for the services subnet with VNet-only and deny-internet rules.
- Apply the configuration and investigate whether your subnets are actually protected.
Create NSGs with security rules
Each subnet needs its own NSG with rules tailored to its purpose. The AKS subnet will accept HTTPS traffic from the internet (for ingress controllers) plus internal VNet traffic. The services subnet will only allow internal VNet communication.
How do NSGs compare to AWS?
NSGs are stateful like AWS Security Groups, but they apply at the subnet level like NACLs. Priority numbers work like NACL rules: a lower number means higher priority. Azure evaluates rules in priority order and stops at the first match.
- In VS Code, open main.tf inside terraform/environments/dev/.
- Find the closing brace of the azurerm_subnet "services" resource at the bottom of the file.
- Add the AKS NSG and its first security rule below it by pasting this code:
resource "azurerm_network_security_group" "aks" {
name = "nsg-aks-${var.environment}-${var.location}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tags = local.common_tags
}
resource "azurerm_network_security_rule" "aks_allow_https_inbound" {
name = "AllowHTTPSInbound"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "Internet"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.main.name
network_security_group_name = azurerm_network_security_group.aks.name
}
What does this code do?
- azurerm_network_security_group "aks" creates an empty NSG container. Rules are defined as separate resources, not inline.
- The HTTPS rule (priority 100) allows TCP port 443 from the Internet. This is needed for future AKS ingress traffic.
- source_address_prefix = "Internet" is an Azure service tag that represents all public IP addresses.
- Add the remaining two AKS rules below the HTTPS rule:
resource "azurerm_network_security_rule" "aks_allow_vnet_inbound" {
name = "AllowVNetInbound"
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "VirtualNetwork"
destination_address_prefix = "VirtualNetwork"
resource_group_name = azurerm_resource_group.main.name
network_security_group_name = azurerm_network_security_group.aks.name
}
resource "azurerm_network_security_rule" "aks_deny_all_inbound" {
name = "DenyAllInbound"
priority = 4000
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.main.name
network_security_group_name = azurerm_network_security_group.aks.name
}
What does this code do?
- The VNet rule (priority 200) allows all protocols and ports between resources inside the VNet. The VirtualNetwork service tag represents all addresses within the VNet address space.
- The deny-all rule (priority 4000) catches anything not matched by higher-priority rules and drops it. This is your explicit safety net.
- Rule evaluation order: Azure processes priority 100 first, then 200, then 4000. The first match wins.
- Save main.tf and confirm your syntax is valid by running this command:
terraform validate
You should see: Success! The configuration is valid.
Seeing a syntax error?
- Check that every opening brace { has a matching closing brace }. A missing brace at the end of a resource block is the most common cause.
- Make sure each azurerm_network_security_rule references azurerm_network_security_group.aks.name for its network_security_group_name attribute.
Still stuck? Help me fix this Terraform validate error for my NSG rules
Now add the services subnet NSG. This one is more restrictive because backend services should never be directly reachable from the internet.
- Add the services NSG and its first rule below the AKS deny-all rule:
resource "azurerm_network_security_group" "services" {
name = "nsg-services-${var.environment}-${var.location}"
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tags = local.common_tags
}
resource "azurerm_network_security_rule" "services_allow_vnet_inbound" {
name = "AllowVNetInbound"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "VirtualNetwork"
destination_address_prefix = "VirtualNetwork"
resource_group_name = azurerm_resource_group.main.name
network_security_group_name = azurerm_network_security_group.services.name
}
What does this code do?
- azurerm_network_security_group "services" creates a second NSG specifically for the services subnet.
- The VNet rule (priority 100) allows only traffic from within your Virtual Network. No internet traffic can reach this subnet.
- Add the deny-internet rule below the services VNet rule:
resource "azurerm_network_security_rule" "services_deny_internet_inbound" {
name = "DenyInternetInbound"
priority = 4000
direction = "Inbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "Internet"
destination_address_prefix = "*"
resource_group_name = azurerm_resource_group.main.name
network_security_group_name = azurerm_network_security_group.services.name
}
What does this code do?
This rule explicitly denies all inbound traffic from the Internet service tag. Combined with the VNet-allow rule at priority 100, only internal VNet traffic passes through.
- Why use separate azurerm_network_security_rule resources instead of inline rules? Inline rules inside the NSG block conflict with standalone rule resources and prevent independent management. Professional Terraform always uses separate resources.
- Save main.tf.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file, then continue below.
ⓧ I'd like to double check the full code
Your main.tf should now contain these sections in order. Scroll up through your file and confirm each is present:
- The locals block (resource_suffix and common_tags).
- azurerm_resource_group "main" resource.
- azurerm_virtual_network "main" resource.
- azurerm_subnet "aks" and azurerm_subnet "services" resources.
- azurerm_network_security_group "aks" followed by three azurerm_network_security_rule resources (aks_allow_https_inbound, aks_allow_vnet_inbound, aks_deny_all_inbound).
- azurerm_network_security_group "services" followed by two azurerm_network_security_rule resources (services_allow_vnet_inbound, services_deny_internet_inbound).
Apply and investigate the result
Your NSG definitions are ready. Time to deploy them and confirm they are protecting your subnets.
- Preview what Terraform will create by running:
terraform plan
You should see 7 new resources planned: 2 NSGs and 5 security rules.
- Apply the changes by running:
terraform apply
- Type yes when prompted to confirm.
Terraform creates all 7 resources. Now verify the NSGs exist in Azure.
- List your NSGs by running:
az network nsg list --resource-group rg-cloudplatform-dev-eastus --output table
You should see nsg-aks-dev-eastus and nsg-services-dev-eastus listed in the table.
The NSGs exist. But are your subnets actually protected by them? Before you check, predict: do you think Azure automatically applies an NSG to subnets in the same resource group?
- Check whether the AKS subnet has an NSG attached by running:
az network vnet subnet show --resource-group rg-cloudplatform-dev-eastus --vnet-name vnet-cloudplatform-dev-eastus --name snet-aks-dev-eastus --query networkSecurityGroup
The result is null.
Your NSGs exist in the resource group, but they are NOT attached to any subnet. Traffic flows through your subnets completely unfiltered.
This is the gap. It is intentional.
This is one of the most common Azure networking misconfigurations in production. An engineer creates NSGs with perfect rules, assumes the subnet is protected, and only discovers during an incident that the association was never created.
Azure requires an explicit azurerm_subnet_network_security_group_association resource to bind an NSG to a subnet. Creating the NSG alone does nothing. You will fix this in the next step.
You have proven that creating NSGs is only half the job. In the next step, you will add the explicit associations that bind these NSGs to your subnets and validate the complete network.
Associate NSGs to Subnets and Validate the Network
In the previous step, you discovered that your NSGs exist but are not protecting anything. The subnets returned null for their security group. This is a real production incident pattern where engineers assume subnets are protected after creating NSGs.
Azure requires an explicit association resource between an NSG and a subnet. In this step, you will add those associations, apply the fix, and then validate the entire network foundation is correctly wired.
In this step, get ready to:
- Add explicit subnet-NSG association resources to your Terraform configuration.
- Apply the fix and verify subnets now return their NSG IDs.
- Run a full validation checklist confirming zero drift and complete state.
Add subnet-NSG associations
The azurerm_subnet_network_security_group_association resource explicitly binds an NSG to a subnet. Without this, the NSG is just a definition sitting in your resource group doing nothing.
- In VS Code, open main.tf in your terraform/environments/dev/ directory.
- Scroll to the bottom of the file, after the last azurerm_network_security_rule resource.
- Add the following two association resources:
resource "azurerm_subnet_network_security_group_association" "aks" {
subnet_id = azurerm_subnet.aks.id
network_security_group_id = azurerm_network_security_group.aks.id
}
resource "azurerm_subnet_network_security_group_association" "services" {
subnet_id = azurerm_subnet.services.id
network_security_group_id = azurerm_network_security_group.services.id
}
What does this code do?
Each association resource takes two arguments: the subnet ID and the NSG ID. Terraform uses these to call the Azure API and bind the NSG to that specific subnet.
This is the missing link from the previous step. Once applied, traffic to these subnets will be filtered by the NSG rules you already created.
- Now open outputs.tf in the same directory.
- Add the following two outputs at the bottom of the file, after the existing services_subnet_id output:
output "aks_nsg_id" {
description = "ID of the AKS Network Security Group"
value = azurerm_network_security_group.aks.id
}
output "services_nsg_id" {
description = "ID of the services Network Security Group"
value = azurerm_network_security_group.services.id
}
Why add NSG outputs?
These outputs expose the NSG resource IDs so that future Terraform configurations (like the AKS module you will build later) can reference them. Outputs are how Terraform modules communicate values across boundaries.
- Save both main.tf and outputs.tf.
Apply and verify the fix
Before you run this, think about what you expect to see. How many new resources should Terraform plan to create? The associations are new resources, not modifications to existing ones.
- Preview the changes by running this command:
terraform plan
You should see 2 resources to add (the two association resources) and 0 to change or destroy. The existing NSGs and subnets are untouched.
- Apply the changes by running:
terraform apply
- Type yes when prompted to confirm.
Now let's confirm the fix actually worked. In the previous step, querying the AKS subnet returned null for its NSG. Let's check again.
- Verify the AKS subnet now has its NSG by running:
az network vnet subnet show --resource-group rg-cloudplatform-dev-eastus --vnet-name vnet-cloudplatform-dev-eastus --name snet-aks-dev-eastus --query networkSecurityGroup.id --output tsv
You should see a full Azure resource ID ending in nsg-aks-dev-eastus. No more null.
- Verify the services subnet by running:
az network vnet subnet show --resource-group rg-cloudplatform-dev-eastus --vnet-name vnet-cloudplatform-dev-eastus --name snet-services-dev-eastus --query networkSecurityGroup.id --output tsv
This should return a resource ID ending in nsg-services-dev-eastus. Both subnets are now actively protected by their NSG rules.
Still seeing null?
Make sure you saved main.tf before running terraform apply. Check that your association resources reference azurerm_subnet.aks.id and azurerm_network_security_group.aks.id (not the name strings).
help me debug why my subnet-NSG association isn't working in Terraform
- Confirm your security rules are active on the AKS NSG by running:
az network nsg rule list --resource-group rg-cloudplatform-dev-eastus --nsg-name nsg-aks-dev-eastus --output table
You should see your three rules listed: AllowHTTPSInbound (priority 100), AllowVNetInbound (priority 200), and DenyAllInbound (priority 4000).
Full validation checklist
Your network is now fully wired. Let's run through a complete validation to confirm everything is consistent between Terraform's state and Azure's actual state.
- Confirm zero drift by running:
terraform plan
You should see "No changes. Your infrastructure matches the configuration." This proves Terraform's state and Azure's reality are perfectly in sync.
- Review all managed resources by running:
terraform state list
You should see your resource group, VNet, two subnets, two NSGs, all security rules, and two association resources listed.
- View your network outputs by running:
terraform output
You should see all seven outputs: resource_group_name, vnet_id, vnet_name, aks_subnet_id, services_subnet_id, aks_nsg_id, and services_nsg_id. These are the values future modules will reference when connecting workloads to your network.
- Finally, confirm your remote state was updated by running:
az storage blob show --container-name tfstate --account-name stcloudplatformtfstate --name dev.terraform.tfstate --query "properties.lastModified"
The timestamp should reflect the time of your most recent terraform apply. Your state is safely stored in Azure Blob Storage with lease-based locking.
What does zero drift mean?
Zero drift means nothing has changed in Azure outside of Terraform's control. If someone manually modified a resource in the Azure portal, terraform plan would show differences. Seeing no changes confirms you are the single source of truth for this infrastructure.
✔️ Awesome, I've got everything!
Great. Double check that both main.tf and outputs.tf are saved and that terraform plan shows zero changes.
ⓧ I'd like to double check the full code
Here is the full outputs.tf file with all seven outputs:
output "resource_group_name" {
description = "Name of the resource group"
value = azurerm_resource_group.main.name
}
output "vnet_id" {
description = "ID of the Virtual Network"
value = azurerm_virtual_network.main.id
}
output "vnet_name" {
description = "Name of the Virtual Network"
value = azurerm_virtual_network.main.name
}
output "aks_subnet_id" {
description = "ID of the AKS subnet"
value = azurerm_subnet.aks.id
}
output "services_subnet_id" {
description = "ID of the services subnet"
value = azurerm_subnet.services.id
}
output "aks_nsg_id" {
description = "ID of the AKS Network Security Group"
value = azurerm_network_security_group.aks.id
}
output "services_nsg_id" {
description = "ID of the services Network Security Group"
value = azurerm_network_security_group.services.id
}
For main.tf, confirm you have all the resources from earlier steps (locals, resource group, VNet, two subnets, two NSGs, all security rules) plus the two azurerm_subnet_network_security_group_association resources at the bottom. Running terraform plan should show no changes if everything matches.
Your Azure network foundation is complete. You have a VNet with CIDR-planned subnets, NSGs enforcing traffic rules, explicit associations binding them together, and Terraform managing it all with remote state. Next up: the Secret Mission challenges you to refactor this into a reusable module and prepare a production environment.
Secret mission
Refactor into a Module and Deploy Prod
In a real Azure Cloud Engineer role, you would never copy-paste Terraform files between environments. You would write reusable modules and change only the variables. Refactor your networking into a module, prove it is transparent to your existing dev infrastructure, and scaffold a prod environment that reuses the same code with different CIDR ranges.
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. This project's Azure resources cost approximately $0.02/month (the storage account for Terraform state). VNets, subnets, and NSGs are free.
Cost warning
The storage account stcloudplatformtfstate generates approximately $0.02/month in ongoing charges (Standard_LRS, a few KB of state files). If you do not plan to continue to the next project in this series, delete the resources to avoid any charges.
Resources you used:
- Storage Account (stcloudplatformtfstate) in rg-terraform-state for Terraform remote state (~$0.02/month).
- Resource Group (rg-cloudplatform-dev-eastus) containing VNet, subnets, and NSGs (free).
- Local project files at ~/azure-cloud-platform/ in WSL.
Keep everything running
No action needed. Choose this if you plan to continue to the next project in this series (AKS + ACR), which builds directly on this network foundation.
- Total ongoing cost is approximately $0.02/month for the storage account.
- VNets, subnets, and NSGs are free and generate no charges.
- Azure CLI remains installed in your WSL environment, ready for future projects.
Pause - I'll come back to this later
These resources do not have running compute, so there is nothing to pause or stop. They either exist or they do not.
- The storage account is the only resource with ongoing cost (~$0.02/month). If this is acceptable, leave everything as-is.
- If you want to eliminate all charges while keeping your local Terraform files for reference, follow the Delete tab but skip the rm -rf step for local files.
Delete - I don't want to use this again
Remove all Azure resources and local project files to start completely fresh.
- Destroy the dev environment infrastructure by running these commands in WSL:
cd ~/azure-cloud-platform/terraform/environments/dev
terraform destroy
- Terraform will show you a plan of all resources to be destroyed. Type yes when prompted.
- Delete the state storage resource group (this removes the storage account and all state files):
az group delete --name rg-terraform-state --yes --no-wait
- The --no-wait flag returns immediately while Azure deletes the resource group in the background.
- Remove local project files:
cd ~
rm -rf azure-cloud-platform
- Verify everything is gone by running:
az group list --output table
- You should no longer see rg-cloudplatform-dev-eastus or rg-terraform-state in the output.
Nice Work!
Nice Work!
You just built a production-style Azure network foundation entirely through Terraform with remote state. This is the exact infrastructure layer an Azure Cloud Engineer deploys on Day 1 of a new platform.
You learned how to:
- Build a professional Terraform project with pinned provider versions (AzureRM 5.0.1) and remote state in Azure Blob Storage with built-in lease-based locking.
- Deploy an Azure Virtual Network with CIDR-planned subnets ready for AKS and supporting services.
- Diagnose the unassociated-NSG problem (a real production incident pattern) and fix it by adding explicit subnet-NSG associations.
- Secret Mission: Refactored networking into a reusable Terraform module and prepared a prod environment structure, following the professional pattern of environment separation through variable files.
Ready to quiz yourself?