Hello and welcome everyone to my third post in my series Terraform on Azure! My last post I went into great detail with how to use Azure DevOps along with Terraform to provision infrastructure to our Azure subscription.
The infrastructure provisioned in that network was a traditional Hub and Spoke network. However, in order for each spoke to communicate to each other a Network Virtual Appliance is required. To fulfill that requirement, let’s deploy Azure Firewall. This configuration includes a Layer 7 policy for outbound internet access for both spokes along with a typical Layer 4 policy for spoke to spoke communication.
Azure Bastion will also be setup for secure management of our Azure network. So let’s get started.
Directory Structure
Azure_HubAndSpoke_folder
└──network.tf
└──providers.tf
└──terraform.tfvars
└──bastion.tf
└──routes.tf
└──firewall.tf
Directory Files
network.tf
This Terraform file builds out the building blocks for our network. Creating our Hub Virtual network, along with two spokes peered with the Hub VNET.
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 "useast-firewall-subnet-name" {
type = string
}
variable "useast-firewall-subnet-address" {
type = string
}
variable "azurerm_spoke2_subnet1_name" {
type = string
}
variable "useast-bastion-subnet-name" {
type = string
}
variable "useast-bastion-subnet-address" {
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_subnet_route_table_association" "hub-subnet1-udr" {
subnet_id = azurerm_subnet.useast-hub-subnet1.id
route_table_id = azurerm_route_table.eastus-route-table.id
}
resource "azurerm_subnet" "useast-bastion-subnet" {
name = var.useast-bastion-subnet-name
address_prefix = var.useast-bastion-subnet-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_subnet_route_table_association" "spoke1-subnet1-udr" {
subnet_id = azurerm_subnet.useast-spoke1-subnet1.id
route_table_id = azurerm_route_table.eastus-route-table.id
}
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_subnet_route_table_association" "spoke2-subnet1-udr" {
subnet_id = azurerm_subnet.useast-spoke2-subnet1.id
route_table_id = azurerm_route_table.eastus-route-table.id
}
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
]
}
bastion.tf
Within this Terraform file is where Azure bastion is created. This allows us secure access into our environment by SSH or RDP. We do not have to publicly expose these ports!
Also inside this configuration file I am using Data Sources, which allows the network.tf to be the central location for all network related changes. As you can see below the data block, I am calling the useast-bastion-subnet from the remote state.
The deployment will create a Public IP for Azure Bastion, and create an Azure Bastion host within our Hub VNET. Since VNET peering is enabled with our spokes, only a single Azure Bastion host is required to manage the spokes.
data "azurerm_subnet" "useast-bastion-subnet" {
name = var.useast-bastion-subnet-name
virtual_network_name = var.azurerm_hub_network_name
resource_group_name = var.resource_group_name
depends_on = [
azurerm_subnet.useast-bastion-subnet
]
}
resource "azurerm_resource_group" "eastus-bastion-rg" {
name = "eastus-bastion-rg"
location = "East US"
}
resource "azurerm_public_ip" "eastus-bastion-pip" {
name = "eastus-bastion-pip"
location = "East US"
resource_group_name = azurerm_resource_group.eastus-bastion-rg.name
allocation_method = "Static"
sku = "Standard"
}
resource "azurerm_bastion_host" "useast-bastion-host" {
name = "useast-bastion-host"
location = "East US"
resource_group_name = azurerm_resource_group.eastus-bastion-rg.name
ip_configuration {
name = "configuration"
subnet_id = data.azurerm_subnet.useast-bastion-subnet.id
public_ip_address_id = azurerm_public_ip.eastus-bastion-pip.id
}
depends_on = [
data.azurerm_subnet.useast-bastion-subnet
]
}
routes.tf
As you can very well guess with this TF file contains the routes within our Azure environment. The network.tf file is where the subnet UDR association exists.
resource "azurerm_resource_group" "eastus-routes-rg" {
name = "eastus-routes-rg"
location = "East US"
}
resource "azurerm_route_table" "eastus-route-table" {
name = "eastus-route-table"
location = "East US"
resource_group_name = azurerm_resource_group.eastus-routes-rg.name
}
resource "azurerm_route" "eastus-primary-route" {
name = "eastus-primary-route"
resource_group_name = azurerm_resource_group.eastus-routes-rg.name
route_table_name = azurerm_route_table.eastus-route-table.name
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.eastus-hub-fw.ip_configuration[0].private_ip_address
}
firewall.tf
In my opinion, this is most exciting file within this project, as this defines our Azure Firewall component. Both an Application Rule Collection, and Network Rule Collection are created in order to steer L7 & L4 traffic through the Azure Firewall. This deployment utilizes modern policies, not just classic rules which I found fun to setup.
data "azurerm_resource_group" "useast-network-rg" {
name = "useast-network-rg"
depends_on = [
azurerm_resource_group.useast-network-rg
]
}
data "azurerm_firewall_policy" "eastus-hub-fwpolicy" {
name = "eastus-hub-fwpolicy"
resource_group_name = data.azurerm_resource_group.useast-network-rg.name
depends_on = [
azurerm_resource_group.useast-network-rg
]
}
data "azurerm_virtual_network" "useast-hub-vnet" {
name = "useast-hub-vnet"
resource_group_name = "useast-network-rg"
depends_on = [
azurerm_virtual_network.useast-hub-vnet
]
}
resource "azurerm_subnet" "useast-hub-fwsubnet" {
name = var.useast-firewall-subnet-name
address_prefix = "${var.useast-firewall-subnet-address}"
virtual_network_name = var.azurerm_hub_network_name
resource_group_name = data.azurerm_resource_group.useast-network-rg.name
depends_on = [
azurerm_virtual_network.useast-hub-vnet,
]
}
resource "azurerm_public_ip" "eastus-firewall-pip" {
name = "eastus-firewall-pip"
location = "East US"
sku = "Standard"
resource_group_name = data.azurerm_resource_group.useast-network-rg.name
allocation_method = "Static"
}
resource "azurerm_firewall" "eastus-hub-fw" {
name = "eastus-hub-fw"
location = data.azurerm_resource_group.useast-network-rg.location
resource_group_name = data.azurerm_resource_group.useast-network-rg.name
firewall_policy_id = data.azurerm_firewall_policy.eastus-hub-fwpolicy.id
ip_configuration {
name = "firewall_ip_configuration"
subnet_id = azurerm_subnet.useast-hub-fwsubnet.id
public_ip_address_id = azurerm_public_ip.eastus-firewall-pip.id
}
}
resource "azurerm_firewall_policy" "eastus-hub-fwpolicy" {
name = "eastus-hub-fwpolicy"
resource_group_name = data.azurerm_resource_group.useast-network-rg.name
location = data.azurerm_resource_group.useast-network-rg.location
}
resource "azurerm_firewall_policy_rule_collection_group" "eastus-hub-fwpolicy-rgcollection" {
name = "eastus-hub-rgcollectiongroup"
firewall_policy_id = azurerm_firewall_policy.eastus-hub-fwpolicy.id
priority = 500
application_rule_collection {
name = "inet_outbound_rule_collection"
priority = 1000
action = "Allow"
rule {
name = "eastus_vnets_inet_outbound"
protocols {
port = "443"
type = "Https"
}
source_addresses = [
"${var.azurerm_spoke1_subnet1_address}",
"${var.azurerm_spoke2_subnet1_address}",
]
destination_fqdns = ["*"]
}
}
network_rule_collection {
name = "eastus-spoke1-spoke2-allow"
priority = 200
action = "Allow"
rule {
name = "spoke1_to_spoke2"
protocols = ["TCP", "UDP", "ICMP"]
source_addresses = [
"${var.azurerm_spoke1_subnet1_address}",
]
destination_addresses = [
"${var.azurerm_spoke2_subnet1_address}",
]
destination_ports = [
"*"
]
}
rule {
name = "spoke2_to_spoke1"
protocols = ["TCP", "UDP", "ICMP"]
source_addresses = [
"${var.azurerm_spoke2_subnet1_address}",
]
destination_addresses = [
"${var.azurerm_spoke1_subnet1_address}",
]
destination_ports = [
"*"
]
}
}
}
providers.tf
This is a pretty boring file, just defining the provider Terraform is going to use, in this case AzureRM. It’s boring, but 100% required for the deployment.
provider "azurerm" {
features {}
}
terraform.tfvars
This file contains all the variables that can be used throughout this deployment. Allowing a single source for all the relevant variables used for deployment.
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"
useast-bastion-subnet-name = "AzureBastionSubnet"
useast-bastion-subnet-address = "10.0.2.0/24"
useast-firewall-subnet-name = "AzureFirewallSubnet"
useast-firewall-subnet-address = "10.0.3.0/24"
This creates the solid building blocks for your Azure environment as a whole by setting up a hub and spoke architecture, all flowing through your Azure Firewall. This will set us up for many deployments moving forward.
If you have been following along, if you commit this to your GIthub Repo, it will provision the infrastructure in your environment. At $36 a day the Azure Firewall is not cheap! So be sure to clean up your resources after this is built & tested with terraform destroy.
I am very excited about this deployment and looking forward to continue to build on top of this as the series progresses. It can only get better.