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

Architecture

What This Architecture Enables

This infrastructure design delivers centralized, secure, and controlled access across the Azure environment, with the following key outcomes:

Access Control

  1. 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.

  1. 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

  1. Direct access to internal VMs from the internet is blocked.

Internet Egress Security

  1. 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

  1. 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.

  1. East-West Traffic Visibility:
  • Internal communications (Jumpbox → Web/App VMs) are visible and controllable through the Firewall if required.

Monitoring & Security Analytics

  1. Log Analytics Workspace Integration:
  • Azure resources (NSGs, VMs, Firewall) are connected to Log Analytics for Traffic logs, Network security alerts
  1. 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

  1. 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!!.