Deploying Virtual Networks in Azure via Terraform

Deploying Virtual Networks in Azure via Terraform

Creating virtual networks in Azure is the backbone of any environment you're planning on deploying in the Azure cloud. In this lesson we'll ensure that you're able to deploy multiple VNet's to any region of your choice following standardized naming conventions to keep track of everything.

What is a Virtual Network?

An Azure Virtual Network (VNet) is a service in Microsoft Azure that provides a private network environment for connecting and managing resources within the cloud securely. It functions as a cloud-based model of a traditional on-premise network, offering scalability, availability, and isolation throughout Azure's infrastructure.

What are VNet's Used for?

  1. Private Networking - VNets allow you to define custom private or public IP address ranges for your Azure resources. This ensures resources within VNet's can communicate without exposure to the public internet.
  2. Segmentation with Subnets - VNets can be divided into subnets, segmenting address space further. This helps organize resources and improves IP allocation efficiency. This also allows for Network Security Groups (NSGs) being applied to subnets to control traffic flows.
  3. Connectivity Options:
    Azure Resources Communication: VNets enable secure communication between Azure resources. (Ex: virtual machines (VMs), Kubernetes clusters, App Service Environments, etc.)

    Internet Connectivity: Resources within a VNet can communicate outbound to the internet by default. Inbound connections are possible using public IP addresses or load balancers.

    On-Premises Integration: VNets support hybrid cloud setups by enabling secure connectivity to on-premise networks through VPN Gateways or ExpressRoutes

Applying VNet's to Your Repo

At our company, we’ve developed a Terraform-based approach to deploy Azure Virtual Networks (VNets) that balances flexibility, scalability, and maintainability. Our repository contains a base template organized into reusable modules, enabling us to deploy separate VNets for each Azure region while adhering to strict naming conventions and minimizing code duplication. Each VNet is paired with corresponding Network Security Groups (NSGs) and follows a naming pattern that distinguishes between production (corp-prod) and non-production (non-prod) environments, appending a unique identifier like -001 for each new deployment. In this section, I’ll walk through how our Terraform code achieves these goals, emphasizing the use of modules and variables.

The Modular Approach

The cornerstone of our strategy is a reusable Terraform module that encapsulates the logic for creating a VNet and its associated resources. Instead of writing repetitive code for each region or environment, we define the VNet configuration once in a module and call it multiple times with different parameters. This reduces redundancy and ensures consistency across deployments.

Here’s how we structure it:

  • Main Configuration: In our root Terraform configuration, we call the VNet module for each region and environment combination.
  • Module: The module defines the VNet, its subnets, and corresponding NSGs, using variables to customize each instance.

For example, to deploy a production VNet in the East US region with two subnets, our main configuration might look like this:

module "vnet_eastus_prod" {
  source            = "./modules/vnet"
  region            = "eastus"
  environment       = "corp-prod"
  deployment_number = "001"
  subnets           = [
    { name = "subnet1", address_prefix = "10.0.1.0/24" },
    { name = "subnet2", address_prefix = "10.0.2.0/24" }
  ]
  resource_group_name = "rg-eastus-corp-prod-001"
}

For a non-production VNet in West US, we’d simply call the module again with adjusted parameters:

module "vnet_westus_nonprod" {
  source            = "./modules/vnet"
  region            = "westus"
  environment       = "non-prod"
  deployment_number = "001"
  subnets           = [
    { name = "subnet1", address_prefix = "10.1.1.0/24" }
  ]
  resource_group_name = "rg-westus-non-prod-001"
}

This modular design allows us to scale effortlessly to any number of regions or environments without duplicating code.

Maintaining Naming Conventions

Our naming convention—e.g., vnet-eastus-corp-prod-001—ensures that resources are easily identifiable and trackable. To enforce this systematically, we use Terraform variables and locals to construct names dynamically. Here’s how it works inside the VNet module (modules/vnet/main.tf):

variable "region" {
  type = string
}
variable "environment" {
  type = string
}
variable "deployment_number" {
  type = string
}
variable "subnets" {
  type = list(object({
    name           = string
    address_prefix = string
  }))
}
variable "resource_group_name" {
  type = string
}

locals {
  vnet_name = "vnet-${var.region}-${var.environment}-${var.deployment_number}"
}
  • Variables: We pass in region, environment, and deployment_number to customize each deployment. The subnets variable is a list of objects, allowing us to define multiple subnets flexibly.
  • Locals: The vnet_name local combines these variables into a consistent name, such as vnet-eastus-corp-prod-001. By centralizing the naming logic here, we ensure uniformity and make it easy to update the convention if needed.

The VNet resource uses this name:

resource "azurerm_virtual_network" "vnet" {
  name                = local.vnet_name
  location            = var.region
  resource_group_name = var.resource_group_name
  address_space       = ["10.0.0.0/16"]  # Adjustable via variables in practice
}

For subnets and NSGs, we extend this convention dynamically using Terraform’s for_each construct, which iterates over the subnets list:

resource "azurerm_subnet" "subnets" {
  for_each = { for s in var.subnets : s.name => s }

  name                 = each.value.name
  resource_group_name  = var.resource_group_name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = [each.value.address_prefix]
}

resource "azurerm_network_security_group" "nsgs" {
  for_each = { for s in var.subnets : s.name => s }

  name                = "nsg-${local.vnet_name}-${each.value.name}"
  location            = var.region
  resource_group_name = var.resource_group_name
  # Security rules defined here as needed
}

resource "azurerm_subnet_network_security_group_association" "associations" {
  for_each = { for s in var.subnets : s.name => s }

  subnet_id                 = azurerm_subnet.subnets[each.key].id
  network_security_group_id = azurerm_network_security_group.nsgs[each.key].id
}
  • Subnets: Each subnet retains the name provided in the subnets list (e.g., subnet1), keeping it simple and descriptive.
  • NSGs: Each NSG’s name builds on the VNet name, appending the subnet name—e.g., nsg-vnet-eastus-corp-prod-001-subnet1. This ties the NSG to both the VNet and its specific subnet, enhancing traceability.
  • Associations: The NSGs are linked to their respective subnets, ensuring traffic control is applied correctly.

The -001 suffix increments for each new deployment (e.g., -002, -003), allowing us to track multiple VNets in the same region if needed, such as for different projects or updates.

Minimizing Excessive Code with Reusable Variables

By leveraging variables and loops, we eliminate the need to hardcode resource definitions for every VNet, subnet, or NSG. Here’s how this approach reduces code:

  1. Single Module, Multiple Uses: The VNet module is written once but reused across all regions and environments. Without modules, we’d duplicate hundreds of lines for each deployment.
  2. Dynamic Resource Creation: The for_each loop creates subnets and NSGs based on the subnets list. Adding a subnet is as simple as updating the list—no new code required.
  3. Centralized Naming: Using locals for names avoids repeating the naming logic, reducing errors and maintenance overhead.
  4. Flexible Configuration: Variables like address_space or security rules (omitted here for brevity) can be parameterized, allowing customization without altering the module’s structure.

Contrast this with a non-modular approach: we’d manually define each VNet, subnet, and NSG, hardcoding names and configurations. This would be error-prone, time-consuming, and difficult to update.

Scaling and Managing Deployments

This setup scales seamlessly:

  • New Region: Call the module with a different region, like westus.
  • New Environment: Switch environment to non-prod.
  • New Deployment: Increment deployment_number to 002.

For example, a second production VNet in East US becomes:

module "vnet_eastus_prod_002" {
  source            = "./modules/vnet"
  region            = "eastus"
  environment       = "corp-prod"
  deployment_number = "002"
  subnets           = [
    { name = "subnet1", address_prefix = "10.2.1.0/24" }
  ]
  resource_group_name = "rg-eastus-corp-prod-002"
}

The result is a new VNet named vnet-eastus-corp-prod-002, with its NSG as nsg-vnet-eastus-corp-prod-002-subnet1, all created with minimal additional code.

Key Takeaways

This Terraform implementation offers several benefits:

  • Consistency: Modules and locals enforce naming conventions like vnet-<region>-<environment>-<number>, making resources predictable and manageable.
  • Efficiency: Reusable variables and loops minimize code duplication, simplifying maintenance and scaling.
  • Flexibility: Parameterized inputs allow deployments to adapt to any region or environment effortlessly.

By encapsulating VNet creation in a module and leveraging Terraform’s features, we’ve streamlined our Azure infrastructure management, ensuring it’s both robust and adaptable to our company’s needs.

VNet Module (modules/vnet/main.tf)

This module auto-increments the deployment number and deploys a VNet within a uniquely named resource group.

variable "region" {
  type = string
}

variable "environment" {
  type = string
}

data "azurerm_resource_groups" "existing" {}

locals {
  existing_rgs = data.azurerm_resource_groups.existing.names
  matching_rgs = [for name in local.existing_rgs : name if startswith(name, "rg-${var.region}-${var.environment}-")]
  deployment_numbers = [for name in local.matching_rgs : tonumber(regex("\\d+$", name)[0])]
  max_deployment_number = length(local.deployment_numbers) > 0 ? max(local.deployment_numbers...) : 0
  new_deployment_number = format("%03d", local.max_deployment_number + 1)
  resource_group_name = "rg-${var.region}-${var.environment}-${local.new_deployment_number}"
  vnet_name = "vnet-${var.region}-${var.environment}-${local.new_deployment_number}"
}

resource "azurerm_resource_group" "rg" {
  name     = local.resource_group_name
  location = var.region
}

resource "azurerm_virtual_network" "vnet" {
  name                = local.vnet_name
  location            = var.region
  resource_group_name = azurerm_resource_group.rg.name
  address_space       = ["10.0.0.0/16"]
}

APAC Module (modules/apac/main.tf)

This module deploys VNets in APAC regions, each with an auto-incremented deployment number.

variable "environment" {
  type = string
}

module "vnet_australiaeast" {
  source      = "../vnet"
  region      = "australiaeast"
  environment = var.environment
}

module "vnet_southeastasia" {
  source      = "../vnet"
  region      = "southeastasia"
  environment = var.environment
}

module "vnet_japaneast" {
  source      = "../vnet"
  region      = "japaneast"
  environment = var.environment
}

module "vnet_japanwest" {
  source      = "../vnet"
  region      = "japanwest"
  environment = var.environment
}

EMEA Module (modules/emea/main.tf)

This module covers EMEA regions.

variable "environment" {
  type = string
}

module "vnet_westeurope" {
  source      = "../vnet"
  region      = "westeurope"
  environment = var.environment
}

module "vnet_northeurope" {
  source      = "../vnet"
  region      = "northeurope"
  environment = var.environment
}

module "vnet_uksouth" {
  source      = "../vnet"
  region      = "uksouth"
  environment = var.environment
}

module "vnet_ukwest" {
  source      = "../vnet"
  region      = "ukwest"
  environment = var.environment
}

AMER Module (modules/amer/main.tf)

This module handles AMER regions.

variable "environment" {
  type = string
}

module "vnet_eastus" {
  source      = "../vnet"
  region      = "eastus"
  environment = var.environment
}

module "vnet_westus" {
  source      = "../vnet"
  region      = "westus"
  environment = var.environment
}

module "vnet_centralus" {
  source      = "../vnet"
  region      = "centralus"
  environment = var.environment
}

module "vnet_eastus2" {
  source      = "../vnet"
  region      = "eastus2"
  environment = var.environment
}

Root Configuration (main.tf)

This calls the geographical modules.

provider "azurerm" {
  features {}
}

module "apac" {
  source      = "./modules/apac"
  environment = "corp-prod"
}

module "emea" {
  source      = "./modules/emea"
  environment = "corp-prod"
}

module "amer" {
  source      = "./modules/amer"
  environment = "corp-prod"
}

Automating Deployment Numbers and Organizing by Geographical Regions

To enhance our Azure VNet deployment process, we’ve modified our Terraform code to automatically increment deployment numbers and organize modules by geographical regions: APAC, EMEA, and AMER. These changes streamline operations and ensure scalability across our global infrastructure.

Auto-Incrementing Deployment Numbers

We’ve eliminated the need to manually specify deployment numbers (e.g., 001, 002) by updating the VNet module to auto-increment based on existing resource groups. The module:

  1. Queries all resource groups using the azurerm_resource_groups data source.
  2. Filters for those matching rg-<region>-<environment>-<number>.
  3. Extracts the highest deployment number and increments it, formatting it as a three-digit string.
  4. Names the new resource group and VNet with this number (e.g., rg-eastus-corp-prod-001, vnet-eastus-corp-prod-001).

This ensures each deployment in a region is uniquely numbered without manual intervention, reducing errors and effort.

Geographical Modules: APAC, EMEA, AMER

To manage deployments at scale, we’ve introduced modules for APAC, EMEA, and AMER, each encompassing their associated Azure regions:

  • APAC: australiaeast, southeastasia, japaneast, japanwest
  • EMEA: westeurope, northeurope, uksouth, ukwest
  • AMER: eastus, westus, centralus, eastus2

Each module calls the VNet module for its regions, allowing a single command to deploy VNets across an entire area. For example, applying the APAC module creates VNets in all APAC regions, each with its own auto-incremented deployment number.

Benefits

  • Automation: No manual tracking of deployment numbers.
  • Organization: Simplified management by grouping regions geographically.
  • Consistency: Uniform naming across all deployments.

These enhancements make our Terraform setup more efficient and ready for global expansion.

Read next

Pipelines: Azure DevOps (ADO) & Ansible AWX Integration

Pipelines: Azure DevOps (ADO) & Ansible AWX Integration

Pipelines: Azure DevOps (ADO) & Ansible AWX Integration Everything You Need to Know Table of Contents * Overview * Core Components * Prerequisites * Configuration * Validation * Troubleshooting * Conclusion Overview:

Chase Woodard 11 min read