Architecture Overview
This is a blog post about setting up a Point-to-Site VPN inside Azure with a Hub and Spoke architecture. Lets have a quick overview of what we are trying to build here. First thing is we want a way for a user to be able to connect into our network securely with a VPN that they sign into with their Entra ID account. We want all traffic to pass through a firewall so we can create rules to whitelist connections. And lastly we need the user to be able to access multiple spokes from the VPN that is in the central Hub.
So lets look a simple diagram that I created with Draw.io. I’m not exactly an artist but this diagram should be good enough for people to get a quick grasp of what we are trying to do.
In this diagram we have a Hub and two Spokes that are peered with the Hub. In the Hub there is two subnets, one for the VPN Gateway and one for the Azure Firewall to sit into. There is a route table that is associated with the VPN subnet. This table directs traffic that is destined for the two two peered VNETs VM subnets to the Azure Firewall as it’s next hop. It also directs traffic that is destined for the VPN client pool of addresses to the firewall first. I will try and get a picture of this because that address pool is not in my diagram.
In the two Spoke VNETs we have one subnet in each that hold one VM. Those two VM subnets have the same route table associated with them. This one directs traffic that is destined back to the VPN address pool to the firewall. I had to split this into two routes because for some reaon the /24 was not specific enough to overrule the default route. Using a /25 made the routes more specific and they ended up working. It also directs traffic that is going to the internet to the firewall.
A quick example of how this would work is a user connects to the Azure VPN with their Entra ID account. Once they are authenticated with hopefully MFA the user is inside the Hub. Once in the Hub the user can RDP into either of the VMs. When they RPP into the VM, the packets will first travel to the firewall because the route table is directing any traffic destined for the VM subnets to the firewall first. If there are rules for that user to get through the firewall they will be allowed through and once through the firewall will direct the traffic to the VMs. To connect back to the VPN client, the VM will send the traffic back to the firewall first since the next hop is to the firewall. The firewall with then send the traffic back to the users VPN.
VNETs and Subnets
Ok that is a quick overview so now lets go through the Terraform to see how it is built.
First we need to build the VNETs and the subnets in them.
# VNet 1
resource "azurerm_virtual_network" "vnet1" {
name = "hub-prd-easus"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
address_space = ["10.130.0.0/21"]
}
#Azure Firewall Subnet
resource "azurerm_subnet" "hub_firewall_subnet" {
name = "AzureFirewallSubnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet1.name
address_prefixes = ["10.130.0.0/24"]
}
#VPN Gateway Subnet
resource "azurerm_subnet" "hub_vpngw_subnet" {
name = "GatewaySubnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet1.name
address_prefixes = ["10.130.1.0/24"]
}
# VNet 2
resource "azurerm_virtual_network" "vnet2" {
name = "spk1-prd-eaus"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
address_space = ["10.130.8.0/21"]
}
#VM Subnet
resource "azurerm_subnet" "spk1_vm_subnet" {
name = "spk1-vm-subnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet2.name
address_prefixes = ["10.130.8.0/24"]
}
# VNet 3
resource "azurerm_virtual_network" "vnet3" {
name = "spk2-prd-eaus"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
address_space = ["10.130.16.0/21"]
}
#VM Subnet
resource "azurerm_subnet" "spk2_vm_subnet" {
name = "spk2_vm_subnet"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet3.name
address_prefixes = ["10.130.16.0/24"]
}
To use a firewall in a subnet the subnet needs to be named “AzureFirewallSubnet”, if it is some other name the firewll will not be able to be deployed into the subnet. The same thing apply for the VPN gateway, to deploy a VPN gateway into a subnet, the subnet needs to be named “GatewaySubnet”.
Now lets peer the VNETs together. There is one specific piece to this for the VNETs to be able to use the VPN gateway.
# Peering from Hub to Spoke 1
resource "azurerm_virtual_network_peering" "hub_to_spoke1" {
name = "hubToSpoke1"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet1.name
remote_virtual_network_id = azurerm_virtual_network.vnet2.id
allow_forwarded_traffic = true
allow_virtual_network_access = true
allow_gateway_transit = true
use_remote_gateways = false
}
# Peering from Spoke 1 to Hub
resource "azurerm_virtual_network_peering" "spoke1_to_hub" {
name = "spoke1ToHub"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet2.name
remote_virtual_network_id = azurerm_virtual_network.vnet1.id
allow_forwarded_traffic = true
allow_virtual_network_access = true
allow_gateway_transit = false
use_remote_gateways = true
}
# Peering from Hub to Spoke 2
resource "azurerm_virtual_network_peering" "hub_to_spoke2" {
name = "hubToSpoke2"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet1.name
remote_virtual_network_id = azurerm_virtual_network.vnet3.id
allow_forwarded_traffic = true
allow_virtual_network_access = true
allow_gateway_transit = true
use_remote_gateways = false
}
# Peering from Spoke 2 to Hub
resource "azurerm_virtual_network_peering" "spoke2_to_hub" {
name = "spoke2ToHub"
resource_group_name = azurerm_resource_group.rg.name
virtual_network_name = azurerm_virtual_network.vnet3.name
remote_virtual_network_id = azurerm_virtual_network.vnet1.id
allow_forwarded_traffic = true
allow_virtual_network_access = true
allow_gateway_transit = false
use_remote_gateways = true
}
When the peering is going to Hub to Spoke the “allow gateway transit” needs to be true. When peering the other way Spoke to Hub, the selection use_remote_gateways needs to be true. This allows the Hub to offer its gateway up and the Spokes allowed to use the gateway.
VPN Gateway
Ok now lets build the VPN. First things needed is the two public IPs since we are going to use active-active for the gateway.
# Public IP for VPN Gateway
resource "azurerm_public_ip" "vpn_gateway_pip" {
name = "myVpnGatewayPublicIP"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
allocation_method = "Dynamic" # VPN Gateways use dynamic allocation
}
# Public IP for VPN Gateway
resource "azurerm_public_ip" "vpn_gateway_pip2" {
name = "myVpnGatewayPublicIP2"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
allocation_method = "Dynamic" # VPN Gateways use dynamic allocation
}
Now I can create the actual gateway and we need to use those two public IPs just created.
# VPN Gateway
resource "azurerm_virtual_network_gateway" "vpn_gateway" {
name = "myVpnGateway"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
type = "Vpn"
vpn_type = "RouteBased" # For P2S VPN
sku = "VpnGw1" # Adjust size if needed
active_active = true
enable_bgp = false
vpn_client_configuration {
address_space = ["172.16.13.0/24"]
aad_tenant = "https://login.microsoftonline.com/{tenantid}"
aad_audience = "41b23e61-6c1e-4545-b367-cd054e0ed4b4"
aad_issuer = "https://sts.windows.net/{tenantid}/"
vpn_client_protocols = ["OpenVPN"]
vpn_auth_types = ["AAD"]
}
ip_configuration {
name = "vpngateway-ipconfig"
public_ip_address_id = azurerm_public_ip.vpn_gateway_pip.id
subnet_id = azurerm_subnet.hub_vpngw_subnet.id
}
ip_configuration {
name = "vpngateway-ipconfig2"
public_ip_address_id = azurerm_public_ip.vpn_gateway_pip2.id
subnet_id = azurerm_subnet.hub_vpngw_subnet.id
}
}
There a few main pieces here. The first is you need to decide on the type of VPN you want. I want to use Entra ID authentication for the VPN so I am going to use OpenVPN. You can also see I put in “AAD” in the vpn-auth-types. This stands for Azure AD and for some reason they decided to rename that to Entra ID out of the middle of nowhere and they still have Azure AD used in a ton of different configurations and documents. The other thing I needed was my Entra ID tenant ID and the address pool I want to use.
You need to authorize the Azure VPN application in you Entra ID tenant. To do this you need to log in with a role that has the Cloud Application Administrator and go to this link “https://login.microsoftonline.com/common/oauth2/authorize?client_id=41b23e61-6c1e-4545-b367-cd054e0ed4b4&response_type=code&redirect_uri=https://portal.azure.com&nonce=1234&prompt=admin_consent.” Once you do this you should be able to authenticate with your Entra ID account.
Build the Route Tables
#Route table for the hub
resource "azurerm_route_table" "hubroutes" {
name = "hubroutes"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
route {
name = "hub2spk1"
address_prefix = "10.130.8.0/24"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
}
route {
name = "hub2spk2"
address_prefix = "10.130.16.0/24"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
}
route {
name = "gwtovpn"
address_prefix = "172.16.13.0/24"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
}
tags = {
environment = "Dev"
}
}
# Route Table Association with the VPN Gateway Subnet
resource "azurerm_subnet_route_table_association" "vpn_gateway_route_association" {
subnet_id = azurerm_subnet.hub_vpngw_subnet.id
route_table_id = azurerm_route_table.hubroutes.id
}
Now any traffic that comes from the gateway subnet heading to the VM subnets in the other Spoke and the VPN client address pool will first go through the firewall. At the end I had to associate the route with the subnet by using the vpngw ID.
Route table for VMs to direct all traffic to Azure Firewall on outbound.
#Route table for the hub
resource "azurerm_route_table" "spkroutes" {
name = "spkroutes"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
route {
name = "spk2internet"
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
}
#Need two routes for the VPN client pool. For some reason when using the default/24 it was not specific enough and it used the
#default system route instead of the UDR. Because of this I made them more specific and split them into two address ranges.
route {
name = "spktovpn1"
address_prefix = "172.16.13.0/25"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
}
route {
name = "spktovpn2"
address_prefix = "172.16.13.128/25"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.firewall.ip_configuration[0].private_ip_address
}
tags = {
environment = "Dev"
}
}
#Associate route table with both spks vm subnets
resource "azurerm_subnet_route_table_association" "spk1_vm_route_association" {
subnet_id = azurerm_subnet.spk1_vm_subnet.id
route_table_id = azurerm_route_table.spkroutes.id
}
resource "azurerm_subnet_route_table_association" "spk2_vm_route_association" {
subnet_id = azurerm_subnet.spk2_vm_subnet.id
route_table_id = azurerm_route_table.spkroutes.id
}
Traffic that is going outbound to the internet or any traffic going back to the VPN will now go through the firewall first . I associated this table with the two subnets that hold the VMs.
Build the Network Security Groups
Now I need to make some basic NSGs. I do not really want any rules on them because the firewall is going to control most of the traffic. But it is good to have them just in case I could use them later. Maybe for traffic inside the VNETs.
#NSG for spk1 sub
resource "azurerm_network_security_group" "spk1_nsg" {
name = "spk1_nsg"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}
#NSG for spk2 sub
resource "azurerm_network_security_group" "spk2_nsg" {
name = "spk2_nsg"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
}
#Associate with VM subnet on spk1
resource "azurerm_subnet_network_security_group_association" "spoke1_nsg_assoc" {
subnet_id = azurerm_subnet.spk1_vm_subnet.id
network_security_group_id = azurerm_network_security_group.spk1_nsg.id
}
#Associate with VM subnet on spk2
resource "azurerm_subnet_network_security_group_association" "spoke2_nsg_assoc" {
subnet_id = azurerm_subnet.spk2_vm_subnet.id
network_security_group_id = azurerm_network_security_group.spk2_nsg.id
}
Pretty straightforward here. Create the resource and then associate them with the subnet by using the NSG ID and the subnet ID you want.
Create the Virtual Machines
Ok now I am going to build the virtual machines. Because I wanted to get some work with modules, I decided to build a module for this. I name the module azure_vm. Below is the main.tf of the module.
# modules/azure_vm/main.tf
resource "azurerm_virtual_machine" "vm" {
name = var.vm_name
location = var.location
resource_group_name = var.resource_group_name
network_interface_ids = [azurerm_network_interface.nic.id]
vm_size = var.vm_size
storage_image_reference {
publisher = var.image_publisher
offer = var.image_offer
sku = var.image_sku
version = "latest" # Fetch the latest version
}
storage_os_disk {
name = "${var.vm_name}-osdisk"
caching = "ReadWrite"
create_option = "FromImage"
managed_disk_type = "Standard_LRS"
}
os_profile {
computer_name = var.vm_name
admin_username = var.admin_username
admin_password = var.admin_password # Use SSH key for Linux when possible
}
os_profile_windows_config {
provision_vm_agent = true
enable_automatic_upgrades = true
}
tags = var.tags
}
# Network Interface Resource
resource "azurerm_network_interface" "nic" {
name = "${var.vm_name}-nic"
location = var.location
resource_group_name = var.resource_group_name
ip_configuration {
name = "internal"
subnet_id = var.subnet_id
private_ip_address_allocation = "Dynamic"
}
}
# Public IP (Optional)
resource "azurerm_public_ip" "pip" {
name = "${var.vm_name}-pip"
location = var.location
resource_group_name = var.resource_group_name
allocation_method = "Dynamic"
tags = var.tags
}
I have everything needed to build the VMs. I just need to make a variables.tf to create those variables used in main.tf.
# modules/azure_vm/variables.tf
variable "vm_name" {
description = "Name of the virtual machine"
type = string
}
variable "location" {
description = "The location where the VM will be created"
type = string
}
variable "resource_group_name" {
description = "The name of the resource group"
type = string
}
variable "vm_size" {
description = "The size of the virtual machine"
type = string
default = "Standard_DS1_v2"
}
variable "image_publisher" {
description = "The image publisher (e.g., Canonical for Ubuntu or MicrosoftWindowsServer for Windows)"
type = string
}
variable "image_offer" {
description = "The image offer (e.g., UbuntuServer for Ubuntu or WindowsServer for Windows)"
type = string
}
variable "image_sku" {
description = "The image SKU (e.g., 18.04-LTS for Ubuntu or 2019-Datacenter for Windows)"
type = string
}
variable "admin_username" {
description = "Admin username for the VM"
type = string
}
variable "admin_password" {
description = "Admin password for the VM"
type = string
sensitive = true
}
variable "subnet_id" {
description = "The ID of the subnet in which the NIC will be placed"
type = string
}
variable "tags" {
description = "Tags to apply to the resources"
type = map(string)
default = {}
}
Ok here are the variables. They have descriptions of all of them. Now that I have built those two things I should now be able to use the module.
module "vm1" {
source = "./modules/azure_vm"
vm_name = "windowstestVM1"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
vm_size = "Standard_B1s"
image_publisher = "MicrosoftWindowsServer"
image_offer = "WindowsServer"
image_sku = "2019-Datacenter"
admin_username = "testadmin"
admin_password = "Password1234!" # Replace with a secure password. use env variables or a secrets manager.
subnet_id = azurerm_subnet.spk1_vm_subnet.id
tags = {
environment = "dev"
}
}
module "vm2" {
source = "./modules/azure_vm"
vm_name = "windowstestVM2"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
vm_size = "Standard_B1s"
image_publisher = "MicrosoftWindowsServer"
image_offer = "WindowsServer"
image_sku = "2019-Datacenter"
admin_username = "testadmin"
admin_password = "Password1234!" # Replace with a secure password. use env variables or a secrets manager.
subnet_id = azurerm_subnet.spk2_vm_subnet.id
tags = {
environment = "dev"
}
}
Each VM went into their separate subnet. Since I am testing, I decided to use hardcoded passwords. I probably should have just used the Random Terraform provider but this was quicker. This is why modules are nice. Now whenever I create VMs I can quickly call this module and copy this block of code over.
Building the Firewall
Ok now the only thing left to create is the firewall. I am going to need the firewall, public IP, firewall policy, rules in the policy, log analytic workspace, and diagnostic setting to make sure the firewall is working correctly.
# Azure Firewall in Hub VNet
resource "azurerm_firewall" "firewall" {
name = "AZFirewall"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
sku_name = "AZFW_VNet"
sku_tier = "Standard"
ip_configuration {
name = "AzureFirewallSubnet"
subnet_id = azurerm_subnet.hub_firewall_subnet.id
public_ip_address_id = azurerm_public_ip.firewall_pip.id
}
# Associate the firewall with the policy
firewall_policy_id = azurerm_firewall_policy.fwpolicy.id
}
# Public IP for Azure Firewall
resource "azurerm_public_ip" "firewall_pip" {
name = "myFirewallPublicIP"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
allocation_method = "Static"
sku = "Standard"
}
# Define the Firewall Policy
resource "azurerm_firewall_policy" "fwpolicy" {
name = "fwpolicy"
resource_group_name = azurerm_resource_group.rg.name
location = azurerm_resource_group.rg.location
}
Here is the firewall, public IP, and policy created. I have to put the firewall inside the AzureFirewallSubnet I created earlier. I need the public IP so it can use the internet, and the policy is where the rules are going.
Now I can create the rules.
# Define the Firewall Policy Rule Collection Group
resource "azurerm_firewall_policy_rule_collection_group" "fwpolicy-vpn" {
name = "fwpolicy-vpn"
firewall_policy_id = azurerm_firewall_policy.fwpolicy.id
priority = 100
# Network Rule Collection for VPN to Spoke
network_rule_collection {
name = "vpn-vms"
priority = 101
action = "Allow"
rule {
name = "Allow-VPN-to-Spk"
protocols = ["Any"]
source_addresses = ["172.16.13.0/24"]
destination_addresses = ["10.130.8.0/24", "10.130.16.0/24"] # Spoke CIDRs
destination_ports = ["3389"]
}
rule {
name = "Allow-Spk-to-VPN"
protocols = ["Any"]
source_addresses = ["10.130.8.0/24", "10.130.16.0/24"]
destination_addresses = ["172.16.13.0/24"] # # VPN CIDR
destination_ports = ["3389"]
}
}
# Network Rule Collection for VM Outbound
network_rule_collection {
name = "VM-Outbound"
priority = 150
action = "Allow"
rule {
name = "VM-Outbound"
protocols = ["Any"]
source_addresses = ["10.130.8.0/24", "10.130.16.0/24"]
destination_addresses = ["0.0.0.0/0"] # Outbound internet
destination_ports = ["*"]
}
}
}
I need rules to allow RDP from the VPN to the VMs and back to the VPN from the VMs. I also am allowing the VM to speak to the internet. In normal circumstances I would not allow this, and I would specify what exact connections I want the VM to be able to make. One example would be I would let it access Windows Update. Another thing to notes is that there is no Deny All rule needed. The firewall denies all connections by default. Only whitelisted traffic is allowed through it.
With these rules created everything should be able to work correctly. Now I just need to create the logging for it so I can see if the packets are actually hitting the firewall and being let through or if they are being denied.
#LAW to view the firewall logs and check it is working.
resource "azurerm_log_analytics_workspace" "firwalllogsvpn" {
name = "firwalllogsvpn"
location = azurerm_resource_group.rg.location
resource_group_name = azurerm_resource_group.rg.name
sku = "PerGB2018"
retention_in_days = 30
}
resource "azurerm_monitor_diagnostic_setting" "firewall-logs" {
name = "firewalltolaw"
target_resource_id = azurerm_firewall.firewall.id
log_analytics_workspace_id = azurerm_log_analytics_workspace.firwalllogsvpn.id
log_analytics_destination_type = "AzureDiagnostics"
log {
category = "AzureFirewallNetworkRule"
enabled = true
retention_policy {
enabled = true
}
}
}
I just want to see the Network Rule logs which is why that is the only diagnostic setting used.
Terraform Apply
Now I should be able to run a terraform apply and the infrastructure will start being built.
It shoots out a complete list of things it will build. Now I just need to hit yes and it will start.
It is working, which mean I should be able to come back in 10-15 minutes and check if there were any errors. If not I should be able to download the VPN config and RDP into both VMs.
I got errors on the peerings. I believe it is just because they tried to peer at any use the Hubs remote gateway before the gateway was ready.
I ran the apply again and this time everything worked correctly.
Test the RDP
Now I need to go download VPN config from the VPN page.
I hit download VPN client, and it gave me zip file that I extracted.
I need to go to my Azure VPN client app and select the + on the bottom left and click import. I then need to select the azurevpnconfig file that got downloaded from the page above. Once I do that I need to hit connect and log into the VPN with my Entra ID account.
As you can see it is forcing me to use MFA to sign into my account.
Now I am connected to the VPN, and it has my IP address and all of the routes I have access to from it.
Now I am going to test and see if I can RDP into both VMs.
You can see from the top of the RDP session that the IP I am connected to is in the 10.130.8.0/24 subnet.
Now let’s try the 10.130.16.0/24 VM.
As you can see it worked. Now I want to go check the log analytic workspace to see if the logs are showing up on the firewall.
Here is a log of the RDP to 10.130.16.4.
And here is a log of an RDP to 10.130.8.4
So, as you can see everything is working and I can RDP without exposing the VMs to the internet. Now to show it works I am going to delete the firewall rule that allows RDP into the VMs. I will be able to see in the logs that the traffic is getting denied.
It has been denying my connections after deleting the rules so now let check the logs.
As you can see both attempts at RDP were denied.
Conclusion
This is the end of the blog. I was able to create a point-to-site VPN with Azure VPN. One of the main benefits of this VPN is if you have the right license you are able to enforce conditional access policies onto the Azure VPN app in your tenant. So, you can put policies for sign in risk, user risk, MFA enforcement, or if you use Intune for MDM you can make a policy that only allow sign ins to the VPN through compliant devices. Conditional Access policies are very powerful and are a great way to improve security. I don’t have a premium Entra ID tenant, so I was not able to show this stuff. But anyway thanks for reading hope this was at least a little bit interesting.