Why Terraform, and why IaC?
Terraform is a tool created by Hashicorp that allows you to provision infrastructure to many different type of providers (Azure, AWS, GCP, DigitalOcean) by writing code. This post is the first in a series of posts that will dive into provisioning Terraform with Azure Devops Service, and building our enterprise landing zone from the ground up.
Terraform has been around for a few years now and has seemingly become the gold standard of IaC tooling used by organizations worldwide. For good reason, it has excellent readability, contains integrations to most public/private cloud providers allowing multi-cloud deployments.
There are multiple benefits of Infrastructure as Code, such as. Reusability, once you have written out the environment you can destroy/rebuild this on the fly over and over again. The single source of truth concept, you can achieve a level of standardization among deployments using IaC and deploying via a pipeline.
Prerequisites before we get started.
Terraform downloaded and installed either by a package manager (Chocolatey) or have manually setup the environmental variables. To properly test and verify this is working is to open a terminal, and type terraform –version.
An Azure Subscription.
AZ CLI installed.
Visual Studio Code with Terraform extension installed.
Create your remote state file with Azure storage account.
First off what is the remote state file? Well basically it is a shared file that holds the environment state as Terraform is aware of it. This is very important when you wish to run say a Terraform Apply command and not deleting an existing VM or storage account. I have created my storage account useastnetworktfstate, for this purpose.

Directory Structure
The directory structure for this will look as follows, I will subsequently dive into each object in this directory as we move along with this post.
Azure_HubAndSpoke_folder
└──main.tf
└──providers.tf
└──terraform.tfvars
- main.tf – The resources that we are deploying into Azure.
- providers.tf – The variables that resources depend on for configuration.
- network.tfvars – Contains shared resources that will be utilized across multiple deployments.
Terraform Directory Analysis
main.tf
Defines the Azure Resources that we are deploying.
backend "azurerm" {
storage_account_name = "useastnetworktfstate"
resource_group_name = "useast-network-remotestate"
container_name = "tfstate"
key = "terraform.tfstate"
}
}
variable "resource_group_name" {
type = string
}
variable "resource_location" {
type = string
}
variable "azurerm_hub_network_name" {
type = string
}
variable "azurerm_hub_network_address" {
type = string
}
variable "azurerm_hub2spoke1_peername" {
type = string
}
variable "azurerm_hub2spoke2_peername" {
type = string
}
variable "azurerm_spoke1_network_name" {
type = string
}
variable "azurerm_spoke1_network_address" {
type = string
}
variable "azurerm_spoke1_subnet1_name" {
type = string
}
variable "azurerm_spoke1_subnet1_address" {
type = string
}
variable "azurerm_hub_subnet1_name" {
type = string
}
variable "azurerm_hub_subnet1_address" {
type = string
}
variable "azurerm_spoke1tohub_peername" {
type = string
}
variable "azurerm_spoke2_network_name" {
type = string
}
variable "azurerm_spoke2_network_address" {
type = string
}
variable "azurerm_spoke2_subnet1_name" {
type = string
}
variable "azurerm_spoke2_subnet1_address" {
type = string
}
variable "azurerm_spoke2tohub_peername" {
type = string
}
resource "azurerm_resource_group" "useast-network-rg" {
name = var.resource_group_name
location = var.resource_location
}
resource "azurerm_virtual_network" "useast-hub-vnet" {
name = "${var.azurerm_hub_network_name}"
address_space = ["${var.azurerm_hub_network_address}"]
resource_group_name = var.resource_group_name
location = var.resource_location
depends_on = [
azurerm_resource_group.useast-network-rg
]
}
resource "azurerm_virtual_network_peering" "hub_to_spoke1" {
name = "${var.azurerm_hub2spoke1_peername}"
resource_group_name = var.resource_group_name
virtual_network_name = var.azurerm_hub_network_name
remote_virtual_network_id = azurerm_virtual_network.useast-spoke1-vnet.id
allow_virtual_network_access = true
allow_forwarded_traffic = true
depends_on = [
azurerm_resource_group.useast-network-rg,
azurerm_virtual_network.useast-hub-vnet,
azurerm_virtual_network.useast-spoke1-vnet
]
}
resource "azurerm_virtual_network_peering" "hub_to_spoke2" {
name = "${var.azurerm_hub2spoke2_peername}"
resource_group_name = var.resource_group_name
virtual_network_name = var.azurerm_hub_network_name
remote_virtual_network_id = azurerm_virtual_network.useast-spoke2-vnet.id
allow_virtual_network_access = true
allow_forwarded_traffic = true
depends_on = [
azurerm_resource_group.useast-network-rg,
azurerm_virtual_network.useast-hub-vnet,
azurerm_virtual_network.useast-spoke2-vnet
]
}
resource "azurerm_subnet" "useast-hub-subnet1" {
name = var.azurerm_hub_subnet1_name
address_prefix = var.azurerm_hub_subnet1_address
virtual_network_name = var.azurerm_hub_network_name
resource_group_name = var.resource_group_name
depends_on = [
azurerm_virtual_network.useast-hub-vnet,
]
}
resource "azurerm_virtual_network" "useast-spoke1-vnet" {
name = var.azurerm_spoke1_network_name
address_space = [var.azurerm_spoke1_network_address]
resource_group_name = var.resource_group_name
location = var.resource_location
depends_on = [
azurerm_resource_group.useast-network-rg
]
}
resource "azurerm_subnet" "useast-spoke1-subnet1" {
name = var.azurerm_spoke1_subnet1_name
address_prefix = "${var.azurerm_spoke1_subnet1_address}"
virtual_network_name = var.azurerm_spoke1_network_name
resource_group_name = var.resource_group_name
depends_on = [
azurerm_virtual_network.useast-spoke1-vnet,
azurerm_resource_group.useast-network-rg
]
}
resource "azurerm_virtual_network_peering" "spoke1_to_hub" {
name = var.azurerm_spoke1tohub_peername
resource_group_name = var.resource_group_name
virtual_network_name = var.azurerm_spoke1_network_name
remote_virtual_network_id = azurerm_virtual_network.useast-hub-vnet.id
allow_forwarded_traffic = true
allow_virtual_network_access = true
depends_on = [
azurerm_resource_group.useast-network-rg,
azurerm_virtual_network.useast-hub-vnet,
]
}
resource "azurerm_virtual_network" "useast-spoke2-vnet" {
name = var.azurerm_spoke2_network_name
address_space = [var.azurerm_spoke2_network_address]
resource_group_name = var.resource_group_name
location = var.resource_location
depends_on = [
azurerm_resource_group.useast-network-rg
]
}
resource "azurerm_subnet" "useast-spoke2-subnet1" {
name = var.azurerm_spoke2_subnet1_name
address_prefix = "${var.azurerm_spoke2_subnet1_address}"
virtual_network_name = var.azurerm_spoke2_network_name
resource_group_name = var.resource_group_name
depends_on = [
azurerm_virtual_network.useast-spoke2-vnet,
azurerm_resource_group.useast-network-rg
]
}
resource "azurerm_virtual_network_peering" "spoke2_to_hub" {
name = var.azurerm_spoke2tohub_peername
resource_group_name = var.resource_group_name
virtual_network_name = var.azurerm_spoke2_network_name
remote_virtual_network_id = azurerm_virtual_network.useast-hub-vnet.id
allow_forwarded_traffic = true
allow_virtual_network_access = true
depends_on = [
azurerm_resource_group.useast-network-rg,
azurerm_virtual_network.useast-hub-vnet,
azurerm_virtual_network.useast-spoke2-vnet
]
}
Now, obviously there is quite a lot of meat in the main.tf file so let me break it down for you. As this is the primary file, it makes sense as to why there is a lot going on here.
backend – This function is defining our Azure RM backend state file, which is in a private container within the storage account mentioned above.
variables – These are being defined as strings within the main.tf file, however these values are being referenced at the terraform.tfvars file inherently.
resources – Resource Group, Hub Virtual Network, 2 Spoke Virtual Networks.
terraform.tfvars
resource_group_name = "useast-network-rg"
resource_location = "eastus"
azurerm_hub_network_name = "useast-hub-vnet"
azurerm_hub_network_address = "10.0.0.0/16"
azurerm_hub2spoke1_peername = "hub_to_spoke1"
azurerm_hub_subnet1_name = "usnc-hub-subnet1"
azurerm_hub_subnet1_address = "10.0.1.0/24"
azurerm_spoke1_network_name = "useast-spoke1-vnet"
azurerm_spoke1_network_address = "10.1.0.0/16"
azurerm_spoke1_subnet1_name = "useast-spoke1-subnet1"
azurerm_spoke1_subnet1_address = "10.1.1.0/24"
azurerm_spoke1tohub_peername = "spoke1_to_hub"
azurerm_spoke2_network_name = "useast-spoke2-vnet"
azurerm_spoke2_network_address = "10.2.0.0/16"
azurerm_spoke2_subnet1_name = "useast-spoke1-subnet1"
azurerm_spoke2_subnet1_address = "10.2.1.0/24"
azurerm_spoke2tohub_peername = "spoke2_to_hub"
azurerm_hub2spoke2_peername = "hub_to_spoke2"
The values within this file are pretty self-explanatory, instead of manually typing these values over and over again. We simply reference these values with the variables function.
Terraform will default to use a terraform.tfvars file, however if you wish you pass one named something different you can. All you must do is pass the -var-file=”filename.tfvars” command when you run terraform plan/apply.
providers.tf
provider "azurerm" {
version = "2.5.0"
features {}
}
Terraform Deployment
Now it is time to deploy this, firstly we must connect against our Azure subscription.
az login
az account set –subscription Teds-DevEssential-Sub
Sweet, we are now connected to our Azure subscription, resources can now be deployed using Terraform. Let’s first run the Terraform Init this will get our backend configuration setup.

Next, lets run terraform plan this will run through exactly what this code will deploy in Azure for us.
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# azurerm_resource_group.useast-network-rg will be created
+ resource "azurerm_resource_group" "useast-network-rg" {
+ id = (known after apply)
+ location = "eastus"
+ name = "useast-network-rg"
}
# azurerm_subnet.useast-hub-subnet1 will be created
+ resource "azurerm_subnet" "useast-hub-subnet1" {
+ address_prefix = "10.0.1.0/24"
+ enforce_private_link_endpoint_network_policies = false
+ enforce_private_link_service_network_policies = false
+ id = (known after apply)
+ name = "usnc-hub-subnet1"
+ resource_group_name = "useast-network-rg"
+ virtual_network_name = "useast-hub-vnet"
}
# azurerm_subnet.useast-spoke1-subnet1 will be created
+ resource "azurerm_subnet" "useast-spoke1-subnet1" {
+ address_prefix = "10.1.1.0/24"
+ enforce_private_link_endpoint_network_policies = false
+ enforce_private_link_service_network_policies = false
+ id = (known after apply)
+ name = "useast-spoke1-subnet1"
+ resource_group_name = "useast-network-rg"
+ virtual_network_name = "useast-spoke1-vnet"
}
# azurerm_subnet.useast-spoke2-subnet1 will be created
+ resource "azurerm_subnet" "useast-spoke2-subnet1" {
+ address_prefix = "10.2.1.0/24"
+ enforce_private_link_endpoint_network_policies = false
+ enforce_private_link_service_network_policies = false
+ id = (known after apply)
+ name = "useast-spoke1-subnet1"
+ resource_group_name = "useast-network-rg"
+ virtual_network_name = "useast-spoke2-vnet"
}
# azurerm_virtual_network.useast-hub-vnet will be created
+ resource "azurerm_virtual_network" "useast-hub-vnet" {
+ address_space = [
+ "10.0.0.0/16",
]
+ id = (known after apply)
+ location = "eastus"
+ name = "useast-hub-vnet"
+ resource_group_name = "useast-network-rg"
+ subnet {
+ address_prefix = (known after apply)
+ id = (known after apply)
+ name = (known after apply)
+ security_group = (known after apply)
}
}
# azurerm_virtual_network.useast-spoke1-vnet will be created
+ resource "azurerm_virtual_network" "useast-spoke1-vnet" {
+ address_space = [
+ "10.1.0.0/16",
]
+ id = (known after apply)
+ location = "eastus"
+ name = "useast-spoke1-vnet"
+ resource_group_name = "useast-network-rg"
+ subnet {
+ address_prefix = (known after apply)
+ id = (known after apply)
+ name = (known after apply)
+ security_group = (known after apply)
}
}
# azurerm_virtual_network.useast-spoke2-vnet will be created
+ resource "azurerm_virtual_network" "useast-spoke2-vnet" {
+ address_space = [
+ "10.2.0.0/16",
]
+ id = (known after apply)
+ location = "eastus"
+ name = "useast-spoke2-vnet"
+ resource_group_name = "useast-network-rg"
+ subnet {
+ address_prefix = (known after apply)
+ id = (known after apply)
+ name = (known after apply)
+ security_group = (known after apply)
}
}
# azurerm_virtual_network_peering.hub_to_spoke1 will be created
+ resource "azurerm_virtual_network_peering" "hub_to_spoke1" {
+ allow_forwarded_traffic = true
+ allow_gateway_transit = (known after apply)
+ allow_virtual_network_access = true
+ id = (known after apply)
+ name = "hub_to_spoke1"
+ remote_virtual_network_id = (known after apply)
+ resource_group_name = "useast-network-rg"
+ use_remote_gateways = (known after apply)
+ virtual_network_name = "useast-hub-vnet"
}
# azurerm_virtual_network_peering.hub_to_spoke2 will be created
+ resource "azurerm_virtual_network_peering" "hub_to_spoke2" {
+ allow_forwarded_traffic = true
+ allow_gateway_transit = (known after apply)
+ allow_virtual_network_access = true
+ id = (known after apply)
+ name = "hub_to_spoke2"
+ remote_virtual_network_id = (known after apply)
+ resource_group_name = "useast-network-rg"
+ use_remote_gateways = (known after apply)
+ virtual_network_name = "useast-hub-vnet"
}
# azurerm_virtual_network_peering.spoke1_to_hub will be created
+ resource "azurerm_virtual_network_peering" "spoke1_to_hub" {
+ allow_forwarded_traffic = true
+ allow_gateway_transit = (known after apply)
+ allow_virtual_network_access = true
+ id = (known after apply)
+ name = "spoke1_to_hub"
+ remote_virtual_network_id = (known after apply)
+ resource_group_name = "useast-network-rg"
+ use_remote_gateways = (known after apply)
+ virtual_network_name = "useast-spoke1-vnet"
}
# azurerm_virtual_network_peering.spoke2_to_hub will be created
+ resource "azurerm_virtual_network_peering" "spoke2_to_hub" {
+ allow_forwarded_traffic = true
+ allow_gateway_transit = (known after apply)
+ allow_virtual_network_access = true
+ id = (known after apply)
+ name = "spoke2_to_hub"
+ remote_virtual_network_id = (known after apply)
+ resource_group_name = "useast-network-rg"
+ use_remote_gateways = (known after apply)
+ virtual_network_name = "useast-spoke2-vnet"
}
Plan: 11 to add, 0 to change, 0 to destroy.
}
As you can see, this will deploy a Hub Virtual Network along with a two separate spoke virtual networks peered to the hub virtual network. As well my specified resource group, useast-network-rg will be created. This will begin our landing zone as the next post in this series will be implementing Azure Bastion for secure remote management along with Azure Firewall to steer traffic.
I know this was a bit lengthy of a post however I hope it has been informative, please join me as I continue this series.