Azure Hub-and-Spoke Architecture Using Terraform
Description
To design and deploy a secure, scalable Azure infrastructure using a hub-and-spoke topology. This project leverages Terraform for Infrastructure-as-Code (IaC) and includes network segmentation, centralized firewall, controlled access via jumpbox, and log analytics integration.
Structure
hub-spoke-architecture/
├── provider.tf
├── main.tf
├── variables.tf
├── vnet.tf
├── firewall.tf
├── peerings.tf
├── routes.tf
├── jumpbox.tf
├── webvm.tf
├── appvm.tf
└── log_analytics.tf
Diagram
What This Architecture Enables
This infrastructure design delivers centralized, secure, and controlled access across the Azure environment, with the following key outcomes:
Access Control
- SSH Access to Jumpbox via Firewall NAT:
-
Users can securely connect to the Jumpbox VM (private subnet) using the public IP of the Azure Firewall via a NAT rule on port 9494.
-
No public IP is assigned directly to any VM.
- Internal SSH to Other VMs:
-
Once connected to the Jumpbox, users can securely SSH into:
-
Web VM in web-subnet
-
App VM in app-subnet
- Direct access to internal VMs from the internet is blocked.
Internet Egress Security
- All Outbound Internet Traffic Routed Through Azure Firewall:
-
Custom route tables ensure that all egress traffic from Jumpbox, Web VM, and App VM flows through the Azure Firewall.
-
This allows centralized logging, traffic control, and future use of Application or Network Rules for filtering or inspection.
Network Segmentation & Connectivity
- Hub-and-Spoke VNet Peering:
-
VMs in Spoke VNets (Web and App) communicate with the Hub VNet using VNet peering.
-
Peering is configured bidirectionally to allow seamless, low-latency internal traffic between Jumpbox and other VMs.
- East-West Traffic Visibility:
- Internal communications (Jumpbox → Web/App VMs) are visible and controllable through the Firewall if required.
Monitoring & Security Analytics
- Log Analytics Workspace Integration:
- Azure resources (NSGs, VMs, Firewall) are connected to Log Analytics for Traffic logs, Network security alerts
- Future-ready for Azure Sentinel:
The Log Analytics Workspace can be easily integrated with Azure Sentinel for SIEM and advanced threat detection.(if required)
Scalability & Maintainability
- Infrastructure is modular and written using Terraform IaC:
-
Easy to replicate or extend to new regions or environments (e.g., staging, prod)
-
Simplifies VM scaling, NSG rule additions, or routing logic adjustments
Terraform Scripts
provider.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~>3.0"
}
}
}
provider "azurerm" {
features {}
}
main.tf
resource "azurerm_resource_group" "hub-spoke-rg01" {
name = var.resource_group_name
location = var.location
}
resource "azurerm_role_assignment" "network_contributor" {
scope = azurerm_resource_group.hub-spoke-rg01.id
role_definition_name = "Network Contributor"
principal_id = "id of the user account(s)"
}
variables.tf
variable "location" {
description = "Azure Region"
default = "East US"
}
variable "resource_group_name" {
description = "Name of the resource group"
default = "hub-spoke-rg01"
}
vnets.tf
#hub-vnet and subnets
resource "azurerm_virtual_network" "hub-vnet" {
name = "hub-vnet"
location = var.location
resource_group_name = var.resource_group_name
address_space = ["10.0.0.0/16"]
}
resource "azurerm_subnet" "AzureFirewallSubnet" {
name = "AzureFirewallSubnet"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.hub-vnet.name
address_prefixes = ["10.0.1.0/24"]
}
resource "azurerm_subnet" "jump-box01-subnet" {
name = "jump-box01-subnet"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.hub-vnet.name
address_prefixes = ["10.0.2.0/24"]
}
#app vnet and subnets
resource "azurerm_virtual_network" "app-vnet-01" {
name = "app-vnet-01"
location = var.location
resource_group_name = var.resource_group_name
address_space = ["10.1.0.0/16"]
}
resource "azurerm_subnet" "app-vm01-subnet" {
name = "app-vm01-subnet"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.app-vnet-01.name
address_prefixes = ["10.1.1.0/24"]
}
#web vnet and subnets
resource "azurerm_virtual_network" "web-vnet01" {
name = "web-vnet01"
resource_group_name = var.resource_group_name
location = var.location
address_space = ["10.2.0.0/16"]
}
resource "azurerm_subnet" "web-vm01-subnet" {
name = "web-vm01-subnet"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.web-vnet01.name
address_prefixes = ["10.2.1.0/24"]
}
resource "azurerm_subnet" "web-vm02-subnet" {
name = "web-vm02-subnet"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.web-vnet01.name
address_prefixes = ["10.2.2.0/24"]
}
firewall.tf
# public IP for azure firewall
resource "azurerm_public_ip" "hub-spoke-firewall01-pub-ip01" {
name = "hub-spoke-firewall01-pub-ip01"
location = var.location
resource_group_name = var.resource_group_name
allocation_method = "Static"
sku = "Standard"
}
#azure firewall
resource "azurerm_firewall" "hub-spoke-firewall01" {
name = "hub-spoke-firewall01"
location = var.location
resource_group_name = var.resource_group_name
sku_name = "AZFW_VNet"
sku_tier = "Standard"
ip_configuration {
name = "hub-spoke-firewall01-ipconfig"
subnet_id = azurerm_subnet.AzureFirewallSubnet.id
public_ip_address_id = azurerm_public_ip.hub-spoke-firewall01-pub-ip01.id
}
}
resource "azurerm_firewall_nat_rule_collection" "hub-spoke-firewall01-nat01" {
name = "hub-spoke-firewall01-nat01"
azure_firewall_name = azurerm_firewall.hub-spoke-firewall01.name
resource_group_name = var.resource_group_name
priority = 120
action = "Dnat"
rule {
name = "azfw-jumpbox-dnat01"
protocols = ["TCP"]
source_addresses = ["*"]
destination_addresses = [azurerm_public_ip.hub-spoke-firewall01-pub-ip01.ip_address]
destination_ports = ["9494"]
translated_address = azurerm_network_interface.jump-box01-nic.private_ip_address
translated_port = "22"
}
}
resource "azurerm_firewall_network_rule_collection" "firewall-internet-outbound" {
name = "firewall-internet-outbound"
azure_firewall_name = azurerm_firewall.hub-spoke-firewall01.name
resource_group_name = var.resource_group_name
priority = 100
action = "Allow"
rule {
name = "firewall-internet-outbound-rule"
protocols = ["Any"]
source_addresses = ["10.0.2.0/24","10.1.0.0/16"]
destination_addresses = ["0.0.0.0/0"]
destination_ports = ["*"]
}
}
#firewall log
resource "azurerm_monitor_diagnostic_setting" "hub-spoke-fw-diag" {
name = "hub-spoke-fw-diag"
target_resource_id = azurerm_firewall.hub-spoke-firewall01.id
log_analytics_workspace_id = azurerm_log_analytics_workspace.hub-spoke-analytics01.id
enabled_log {
category = "AzureFirewallApplicationRule"
}
enabled_log {
category = "AzureFirewallNetworkRule"
}
enabled_log {
category = "AzureFirewallDnsProxy"
}
}
peerings.tf
#web-hub
resource "azurerm_virtual_network_peering" "web-hub" {
name = "web-hub"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.web-vnet01.name
remote_virtual_network_id = azurerm_virtual_network.hub-vnet.id
allow_forwarded_traffic = true
allow_gateway_transit = false
use_remote_gateways = false
}
#hub-web
resource "azurerm_virtual_network_peering" "hub-web" {
name = "hub-web"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.hub-vnet.name
remote_virtual_network_id = azurerm_virtual_network.web-vnet01.id
allow_forwarded_traffic = true
allow_gateway_transit = false
allow_virtual_network_access = false
}
#app-hub
resource "azurerm_virtual_network_peering" "app-hub" {
name = "app-hub"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.app-vnet-01.name
remote_virtual_network_id = azurerm_virtual_network.hub-vnet.id
allow_forwarded_traffic = true
allow_gateway_transit = false
use_remote_gateways = false
}
#hub-app
resource "azurerm_virtual_network_peering" "hub-app" {
name = "hub-app"
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.hub-vnet.name
remote_virtual_network_id = azurerm_virtual_network.app-vnet-01.id
allow_forwarded_traffic = true
allow_gateway_transit = false
allow_virtual_network_access = false
}
routes.tf
# routes for jump-box01
resource "azurerm_route_table" "jump-box01-route" {
name = "jump-box01-route"
location = var.location
resource_group_name = var.resource_group_name
route {
name = "jumpbox-internet"
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.hub-spoke-firewall01.ip_configuration[0].private_ip_address
}
}
resource "azurerm_subnet_route_table_association" "jump-box01-rta" {
subnet_id = azurerm_subnet.jump-box01-subnet.id
route_table_id = azurerm_route_table.jump-box01-route.id
}
# routes for web-vm01
resource "azurerm_route_table" "web-vm01-route" {
name = "web-vm01-route"
location = var.location
resource_group_name = var.resource_group_name
route {
name = "web-vm01-internet"
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.hub-spoke-firewall01.ip_configuration[0].private_ip_address
}
}
resource "azurerm_subnet_route_table_association" "web-vm01-rta" {
subnet_id = azurerm_subnet.web-vm01-subnet.id
route_table_id = azurerm_route_table.web-vm01-route.id
}
# routes for app-vm01
resource "azurerm_route_table" "app-vm01-route" {
name = "app-vm01-route"
location = var.location
resource_group_name = var.resource_group_name
route {
name = "app-vm01-internet"
address_prefix = "0.0.0.0/0"
next_hop_type = "VirtualAppliance"
next_hop_in_ip_address = azurerm_firewall.hub-spoke-firewall01.ip_configuration[0].private_ip_address
}
}
resource "azurerm_subnet_route_table_association" "app-vm01-rta" {
subnet_id = azurerm_subnet.app-vm01-subnet.id
route_table_id = azurerm_route_table.app-vm01-route.id
}
jumpbox.tf
# jump vm - nic
resource "azurerm_network_interface" "jump-box01-nic" {
name = "jump-box01-nic"
resource_group_name = var.resource_group_name
location = var.location
ip_configuration {
name = "jump-box01-nic-ip"
subnet_id = azurerm_subnet.jump-box01-subnet.id
private_ip_address_allocation = "Dynamic"
}
}
#jum vm
resource "azurerm_linux_virtual_machine" "jump-box01" {
name = "jump-box01"
location = var.location
resource_group_name = var.resource_group_name
size = "Standard_B1s"
admin_username = "username"
admin_password = "password"
disable_password_authentication = "false"
network_interface_ids = [azurerm_network_interface.jump-box01-nic.id]
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-focal"
sku = "20_04-lts"
version = "latest"
}
}
resource "azurerm_network_security_group" "jump-vm-nsg" {
name = "jump-vm-nsg"
location = var.location
resource_group_name = var.resource_group_name
security_rule {
name = "allow-ssh-outbound"
priority = 130
direction = "Outbound"
protocol = "Tcp"
access = "Allow"
source_address_prefixes = [azurerm_subnet.jump-box01-subnet.address_prefixes[0]]
source_port_range = "*"
destination_address_prefix = "VirtualNetwork"
destination_port_range = "22"
}
security_rule {
name = "deny-all-outbound"
priority = 200
direction = "Outbound"
access = "Deny"
protocol = "*"
source_port_range = "*"
destination_port_range = "*"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "allow-outbound-to-firewall"
priority = 140
direction = "Outbound"
access = "Allow"
protocol = "*"
source_port_range = "*"
destination_port_ranges = ["80", "443", "53"]
source_address_prefix = "*"
destination_address_prefix = azurerm_firewall.hub-spoke-firewall01.ip_configuration[0].private_ip_address
}
}
resource "azurerm_network_interface_security_group_association" "jump-nic-nsg" {
network_interface_id = azurerm_network_interface.jump-box01-nic.id
network_security_group_id = azurerm_network_security_group.jump-vm-nsg.id
}
webvm.tf
#web-vm01 nic
resource "azurerm_network_interface" "web-vm01-nic" {
name = "web-vm01-nic"
resource_group_name = var.resource_group_name
location = var.location
ip_configuration {
name = "web-vm01-nic-ip"
subnet_id = azurerm_subnet.web-vm01-subnet.id
private_ip_address_allocation = "Dynamic"
}
}
#nsg
resource "azurerm_network_security_group" "web-vm-nsg" {
name = "web-vm-nsg"
location = var.location
resource_group_name = var.resource_group_name
security_rule {
name = "allow-ssh"
priority = "110"
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
source_address_prefix = azurerm_subnet.jump-box01-subnet.address_prefixes[0]
destination_port_range = "22"
destination_address_prefix = "*"
}
security_rule {
name = "allow-internet-firewall"
priority = 110
direction = "Outbound"
protocol = "Tcp"
access = "Allow"
source_port_range = "*"
source_address_prefix = "*"
destination_port_range = "*"
destination_address_prefix = azurerm_firewall.hub-spoke-firewall01.ip_configuration[0].private_ip_address
}
}
resource "azurerm_network_interface_security_group_association" "web-nic-nsg" {
network_interface_id = azurerm_network_interface.web-vm01-nic.id
network_security_group_id = azurerm_network_security_group.web-vm-nsg.id
}
resource "azurerm_linux_virtual_machine" "web-vm01" {
name = "web-vm01"
resource_group_name = var.resource_group_name
location = var.location
size = "Standard_B1s"
admin_username = "username"
admin_password = "password"
network_interface_ids = [azurerm_network_interface.web-vm01-nic.id]
disable_password_authentication = "false"
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-focal"
sku = "20_04-lts"
version = "latest"
}
}
appvm.tf
#app-vm-nic
resource "azurerm_network_interface" "app-vm01-nic" {
name = "app-vm01-nic"
resource_group_name = var.resource_group_name
location = var.location
ip_configuration {
name = "app-vm01-nic-ip"
subnet_id = azurerm_subnet.app-vm01-subnet.id
private_ip_address_allocation = "Dynamic"
}
}
#app-nsg
resource "azurerm_network_security_group" "app-vm-nsg" {
name = "app-vm-nsg"
location = var.location
resource_group_name = var.resource_group_name
security_rule {
name = "allow-ssh"
priority = 110
direction = "Inbound"
protocol = "Tcp"
access = "Allow"
source_port_range = "*"
source_address_prefix = azurerm_subnet.jump-box01-subnet.address_prefixes[0]
destination_port_range = "22"
destination_address_prefix = "*"
}
security_rule {
name = "allow-internet-firewall"
priority = 110
direction = "Outbound"
protocol = "Tcp"
access = "Allow"
source_port_range = "*"
source_address_prefix = "*"
destination_port_range = "*"
destination_address_prefix = azurerm_firewall.hub-spoke-firewall01.ip_configuration[0].private_ip_address
}
}
resource "azurerm_network_interface_security_group_association" "app-nic-nsg" {
network_interface_id = azurerm_network_interface.app-vm01-nic.id
network_security_group_id = azurerm_network_security_group.app-vm-nsg.id
}
resource "azurerm_linux_virtual_machine" "app-vm01" {
name = "app-vm01"
location = var.location
resource_group_name = var.resource_group_name
network_interface_ids = [azurerm_network_interface.app-vm01-nic.id]
admin_username = "username"
admin_password = "password"
disable_password_authentication = "false"
size = "Standard_B1s"
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-focal"
sku = "20_04-lts"
version = "latest"
}
}
loganalytics.tf
resource "azurerm_log_analytics_workspace" "hub-spoke-analytics01" {
name = "hub-spoke-analytics01"
location = var.location
resource_group_name = var.resource_group_name
sku = "PerGB2018"
retention_in_days = "30"
}
Thanks for Reading!!.