Setting Up a 4-Tier Azure VNet Architecture with Terraform IAC

Architecture

In this article, we will explore the automation of a complete 4-tier networking setup on Azure using Terraform as Infrastructure as Code (IaC). The setup will include a resource group containing a Virtual Network (VNet), which will host four distinct subnets: Web-Tier Subnet, App-Tier Subnet, DB-Tier Subnet, and Bastion Host Subnet. Each subnet will have its own corresponding Network Security Group (NSG) that is configured with security best practices, and the NSGs will be associated with their respective subnets to ensure a secure and well-architected infrastructure.

Terraform Settings(versions.tf )

The version.tf file contains configurations specifying the Terraform and provider versions, which help Terraform download the required dependencies and maintain compatibility across environments. By defining these versions, the file ensures that Terraform uses the correct versions of the tools and providers, enabling a stable and predictable infrastructure deployment process.

terraform {
  required_version = "~>1.5.6" # Minor version upgrades are allowed
  required_providers {
    azurerm={
        source  = "hashicorp/azurerm"
        version = "~>4.3.0"
    }

    random={
      source = "hashicorp/random"
      version = ">=3.6.0"
    }
  }

}

provider "azurerm" {
    features {}  
    subscription_id ="XXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXX"
}

Random Resource

In Terraform, a random resource is a type of resource that generates random values, which can be used for various purposes such as passwords, unique names, or tokens. The random values created by these resources are stable between runs, meaning that unless explicitly removed or updated, they persist between Terraform applies. This is helpful for managing resources that require unique or unpredictable inputs.

Use case

In Azure, when creating a storage account, the account name must be unique across the entire Azure cloud. To ensure this uniqueness, we can leverage Terraform's random_string resources. By generating a random string, we can append or prefix it to the storage account name, ensuring that the name remains unique and compliant with Azure's global naming requirements.

In this demo, we’ll utilize the random_string resource to create unique Resource Groups, avoiding potential resource conflicts that may occur after destruction. This uniqueness helps prevent issues caused by Azure caching, which can lead to errors when provisioning new instances multiple times in quick succession. By ensuring unique names for each resource group, we can mitigate these conflicts and ensure smoother deployments.

locals

locals {
  owners               = var.business_unit
  environment          = var.environment_dev
  resource_name_prefix = "${var.business_unit}-${var.environment_dev}"

  common_tags = {
    owners      = local.owners,
    environment = local. Environment
  }

}

In the provided locals block, local variables are defined to streamline and enhance the organization of the Terraform configuration. The owners and environment locals are derived from input variables, ensuring that key attributes can be referenced easily throughout the code. A resource_name_prefix is created by concatenating the business unit and environment, which aids in creating a consistent naming convention for resources. Additionally, a common_tags map is established to standardize tagging across resources, incorporating ownership and environment details. This approach promotes DRY (Don't Repeat Yourself) principles, making the code more maintainable and reducing the likelihood of errors.

Design Virtual Network

Defining Input Variables for Vnet

Maintaining and reusing Terraform code can become a significant challenge when provisioning major resources. Defining input variables helps improve maintainability by allowing centralized management of values, making the code easier to understand and debug when needed. Input variables also play a crucial role in the reusability of Terraform modules, enabling quick adjustments and modifications to resources without rewriting the code. This flexibility makes resource provisioning more efficient and adaptable to change.

As per the design, we will create a Virtual Network (VNet) with four subnets—one each for the web, app, database, and bastion tiers. To achieve this, we'll define the following input variables: the VNet name, subnet names, and the address space for both the VNet and its respective subnets. These variables will allow us to manage and configure the network infrastructure efficiently, ensuring flexibility and consistency across deployments.

For example, the required variables might include:

  • VNet name: The name of the virtual network.

  • Subnet names: Names for the Web, App, DB, and Bastion subnets.

  • VNet address space: The CIDR block defining the overall VNet address space.

  • Subnet address spaces: CIDR blocks for each subnet, ensuring proper segmentation across the web, app, DB, and bastion tiers.

By defining these input variables, we ensure easy adjustments and reusability of the Terraform code while adhering to best practices in network design.

variable "vnet_name" {
    default = "az-vnet-default"
    description = "Virtual network name"
    type = string
}

variable "vnet_address_space" {
  default = ["10.0.0.0/16"] 
  description = "Virtual network address space"
  type = list(string)
}

variable "web_subnet_name" {
    default = "websubnet"
    description = "Virtual Network Web subnet name"
    type = string

}

variable "web_subnet_address" {
  default = ["10.0.1.0/24"]
  description = "Virtual network web subnet Address Space"
  type = list(string)
}

variable "app_subnet_name" {
    default = "appsubnet"
    description = "Virtual Network App Subnet"
    type = string
}

variable "app_subnet_address" {
  default = ["10.0.11.0/24"]
  description = "Virtual network app subnet address space"
  type = list(string)
}

variable "db_subnet_name" {
    default = "dbsubnet"
    type = string
    description = "Virtual Network DB subnet"
}

variable "db_subnet_address" {
    default = ["10.0.21.0/24"]
    type = list(string)
    description = "Virtual network Database Address space"
}

variable "bastion_subnet_name" {
    default = "bastionsubnet"
    type = string
    description = "Virtual network bastion subnet name"
}
variable "bastion_subnet_address" {
    default = ["10.0.100.0/24"]
    description = "Bastion subnet address space"
    type = list(string)

}

Create Vnet Resource

Now, we’ll proceed to create the Virtual Network (VNet) using the azurerm_virtual_network resource in Terraform, incorporating the necessary input variables and locals for flexibility and scalability. By leveraging these variables and locals, we ensure that the VNet is configured based on predefined values such as the VNet name, address space, and other attributes.

resource "azurerm_virtual_network" "vnet" {

    name = "${local.resource_name_prefix}-${var.vnet_name}"
    location = azurerm_resource_group.rg.location
    resource_group_name = azurerm_resource_group.rg.name
    address_space = var.vnet_address_space
    tags = local.common_tags
}

This configuration uses input variables and locals like var.vnet_name and var.vnet_address_space, local.resource_name_prefix allowing you to easily modify the network parameters without changing the core code. The next steps will involve defining subnets within this VNet, using a similar approach with appropriate input variables.

Create Subnet & Network Security Group

Our next goal is to create Subnets and Network Security Groups (NSGs) for the Web, App, DB, and Bastion tiers, define the respective security rules, and associate them accordingly. We'll use the following Terraform resources to achieve this:

  1. azurerm_subnet: To create subnets for each tier (Web, App, DB, Bastion).

  2. azurerm_network_security_group: To create the NSGs for each subnet.

  3. azurerm_network_security_rule: To define security rules (both inbound and outbound) within the NSGs.

  4. azurerm_subnet_network_security_group_association: To associate each NSG with its respective subnet.

By using these resources, we can set up secure network segmentation, ensuring that each subnet is protected according to best practices.

Create Web-Tier Subnet & NSG

# Create Web-Tier Subnet
resource "azurerm_subnet" "web_subnet" {

    name = "${azurerm_virtual_network.vnet.name}-${var.web_subnet_name}"
    virtual_network_name = azurerm_virtual_network.vnet.name

    address_prefixes = var.web_subnet_address       # Referenced from vnet-input-variables
    resource_group_name = azurerm_resource_group.rg.name

}
# Create NSG for web_subnet

resource "azurerm_network_security_group" "web_snet_nsg" {

    name = "${azurerm_subnet.web_subnet.name}-nsg"
    location = azurerm_resource_group.rg.location
    resource_group_name = azurerm_resource_group.rg.name

}

# Associate web_subnet with web_snet_nsg

resource "azurerm_subnet_network_security_group_association" "associate_websnet_webnsg" {
  depends_on = [ azurerm_network_security_rule.web_nsg_rules_inbound ]
  subnet_id = azurerm_subnet.web_subnet.id
  network_security_group_id = azurerm_network_security_group.web_snet_nsg.id

}

# Locals block for security rules
locals {
  web_inbound_port_map = {
  # priority:port
    "100":"80"
    "110":"443"
    "120":"22"

  }
}
# Create NSG Rules using azurerm_network_security_rule resource

resource "azurerm_network_security_rule" "web_nsg_rules_inbound" {
    for_each = local.web_inbound_port_map

    name = "Rule_Port_${each.value}"
    access = "Allow"
    direction = "Inbound"
    network_security_group_name = azurerm_ntetwork_security_group.web_snet_nsg.name
    priority = each.key
    protocol = "Tcp"
    source_port_range = "*"
    destination_port_range = "${each.value}"
    source_address_prefix = "*"
    resource_group_name = azurerm_resource_group.rg.name

}
  1. Creating the Web-Tier Subnet: We first define the azurerm_subnet resource to create a Web-Tier subnet within our virtual network. The name of the subnet is dynamically generated by appending the subnet name to the VNet name, ensuring uniqueness. The address prefix is referenced from the input variables, and the subnet is created within the specified resource group.

  2. Creating the Network Security Group (NSG) for Web Subnet: Using the azurerm_network_security_group resource, we create an NSG specifically for the Web Subnet. This NSG will house all the security rules for managing network traffic to and from the Web-Tier.

  3. Associating the Web Subnet with the NSG: Once the NSG is created, we associate it with the Web Subnet using azurerm_subnet_network_security_group_association. This ensures that all traffic passing through the Web Subnet is governed by the security rules defined in the corresponding NSG.

  4. Defining Security Rules Using a Locals Block: To simplify the creation of multiple security rules, we use a locals block to define a map of inbound ports (e.g., HTTP, HTTPS, SSH) along with their priorities. This makes the rules easily configurable and reusable.

  5. Creating NSG Inbound Rules: With the azurerm_network_security_rule resource, we iterate over the web_inbound_port_map to create individual inbound security rules for the Web-Tier NSG. Each rule allows traffic on a specific port (e.g., 80 for HTTP, 443 for HTTPS) with the corresponding priority, ensuring that the web server is accessible while maintaining security.

These steps collectively demonstrate how we can automate the creation and association of subnets and NSGs, while efficiently managing security rules for the web tier.

Create App-Tier Subnet & NSG

# Create App-Tier Subnet
resource "azurerm_subnet" "app_subnet" {
    name = "${azurerm_virtual_network.vnet.name}-${var.app_subnet_name}"
    virtual_network_name = azurerm_virtual_network.vnet.name
    address_prefixes = var.app_subnet_address
    resource_group_name = azurerm_resource_group.rg.name

}
# Create NSG for Subnet
resource "azurerm_network_security_group" "app_snet_nsg" {

    name = "${azurerm_subnet.app_subnet.name}-nsg"
    location = azurerm_resource_group.rg.location
    resource_group_name = azurerm_resource_group.rg.name

}

# Associate app_subnet with app_snet_nsg

resource "azurerm_subnet_network_security_group_association" "associate_appsnet_appnsg" {
  depends_on = [ azurerm_network_security_rule.app_nsg_rules_inbound ]
  subnet_id = azurerm_subnet.app_subnet.id
  network_security_group_id = azurerm_network_security_group.app_snet_nsg.id

}

# Locals block for security rules
locals {
  app_inbound_port_map = {
  # priority:port
    "100":"80"
    "110":"443"
    "120":"8080"
    "130":"22"

  }
}

# Create NSG Rules using azurerm_network_security_rule resource

resource "azurerm_network_security_rule" "app_nsg_rules_inbound" {
    for_each = local.app_inbound_port_map

    name = "Rule_Port_${each.value}"
    access = "Allow"
    direction = "Inbound"
    network_security_group_name = azurerm_network_security_group.app_snet_nsg.name
    priority = each.key
    protocol = "Tcp"
    source_port_range = "*"
    destination_port_range = "${each.value}"
    source_address_prefix = "*"
    resource_group_name = azurerm_resource_group.rg.name

}
    1. Creating the App-Tier Subnet and NSG: Similar to the Web-Tier, the App-Tier subnet is created using azurerm_subnet, and a dedicated NSG is created using azurerm_network_security_group to secure application traffic.

      1. Associating the App Subnet with the NSG: The azurerm_subnet_network_security_group_association links the NSG to the App Subnet, ensuring the defined rules apply specifically to application traffic.

      2. Defining App-Tier Specific Ports: The locals block defines the inbound port map for the App-Tier, which includes typical application traffic ports such as 8080, along with SSH (port 22) for management access.

      3. Creating App-Specific NSG Rules: Using azurerm_network_security_rule, we dynamically create security rules for the defined ports in the App-Tier, ensuring secure access to application services.

This approach highlights the different ports and configurations needed for the App-Tier, while following the same structure for subnet and NSG creation.

Create DB-Tier Subnet & NSG

# Create DB-Tier Subnet
resource "azurerm_subnet" "db_subnet" {
    name = "${azurerm_virtual_network.vnet.name}-${var.db_subnet_name}"
    virtual_network_name = azurerm_virtual_network.vnet.name
    address_prefixes = var.db_subnet_address
    resource_group_name = azurerm_resource_group.rg.name

}

# Create NSG for Subnet
resource "azurerm_network_security_group" "db_snet_nsg" {

    name = "${azurerm_subnet.db_subnet.name}-nsg"
    location = azurerm_resource_group.rg.location
    resource_group_name = azurerm_resource_group.rg.name

}
# Associate db_subnet with db_snet_nsg

resource "azurerm_subnet_network_security_group_association" "associate_dbsnet_dbnsg" {
  depends_on = [ azurerm_network_security_rule.db_nsg_rules_inbound ]
  subnet_id = azurerm_subnet.db_subnet.id
  network_security_group_id = azurerm_network_security_group.db_snet_nsg.id

}

# Locals block for security rules
locals {
  db_inbound_port_map = {
  # priority:port
    "100":"3306"
    "110":"1433"
    "120":"5432"
  }
}

# Create NSG Rules using azurerm_network_security_rule resource

resource "azurerm_network_security_rule" "db_nsg_rules_inbound" {
    for_each = local.db_inbound_port_map

    name = "Rule_Port_${each.value}"
    access = "Allow"
    direction = "Inbound"
    network_security_group_name = azurerm_network_security_group.db_snet_nsg.name
    priority = each.key
    protocol = "Tcp"
    source_port_range = "*"
    destination_port_range = "${each.value}"
    source_address_prefix = "*"
    resource_group_name = azurerm_resource_group.rg.name

}

For the DB-Tier, we follow a similar approach to creating the subnet and associating an NSG. The azurerm_subnet resource is used to define the DB-Tier subnet, and the azurerm_network_security_group resource creates an NSG specifically for securing database traffic. The azurerm_subnet_network_security_group_association links the NSG to the DB subnet. In this case, the locals block defines ports relevant to database services, such as 3306 for MySQL, 1433 for SQL Server, and 5432 for PostgreSQL. These ports are dynamically handled through the azurerm_network_security_rule resource, ensuring that only the necessary inbound traffic reaches the DB-Tier.

Bastion-Tier Subnet & NSG

# Create Bastion-Tier Subnet
resource "azurerm_subnet" "bastion_subnet" {
    name = "${azurerm_virtual_network.vnet.name}-${var.bastion_subnet_name}"
    virtual_network_name = azurerm_virtual_network.vnet.name
    address_prefixes = var.bastion_subnet_address
    resource_group_name = azurerm_resource_group.rg.name

}

# Create NSG for Subnet
resource "azurerm_network_security_group" "bastion_snet_nsg" {

    name = "${azurerm_subnet.bastion_subnet.name}-nsg"
    location = azurerm_resource_group.rg.location
    resource_group_name = azurerm_resource_group.rg.name

}

# Associate bastion_subnet with bastion_snet_nsg

resource "azurerm_subnet_network_security_group_association" "associate_bastionsnet_bastionnsg" {
  depends_on = [ azurerm_network_security_rule.bastion_nsg_rules_inbound ]
  subnet_id = azurerm_subnet.bastion_subnet.id
  network_security_group_id = azurerm_network_security_group.bastion_snet_nsg.id

}

# Locals block for security rules
locals {
  bastion_inbound_port_map = {
  # priority:port
    "100":"22"
    "110":"3389"
  }
}

# Create NSG Rules using azurerm_network_security_rule resource

resource "azurerm_network_security_rule" "bastion_nsg_rules_inbound" {
    for_each = local.bastion_inbound_port_map

    name = "Rule_Port_${each.value}"
    access = "Allow"
    direction = "Inbound"
    network_security_group_name = azurerm_network_security_group.bastion_snet_nsg.name
    priority = each.key
    protocol = "Tcp"
    source_port_range = "*"
    destination_port_range = "${each.value}"
    source_address_prefix = "*"
    resource_group_name = azurerm_resource_group.rg.name

}

For the Bastion-Tier, the process mirrors the previous tiers but is specifically tailored for secure management access. We create the azurerm_subnet resource for the Bastion Subnet, followed by the azurerm_network_security_group to control access. The azurerm_subnet_network_security_group_association links the NSG to the Bastion Subnet. The locals block defines inbound ports specifically used for management purposes, such as port 22 for SSH and port 3389 for RDP. These ports are secured using the azurerm_network_security_rule resource to allow secure administrative access to the infrastructure while maintaining control over inbound traffic.

Defining terraform.tfvars

business_unit           = "hr"
environment_dev         = "dev"
resource_group_name     = "rg-iaas-terraform"
resource_group_location = "eastus"


vnet_name = "az-vnet-default"

vnet_address_space = ["10.0.0.0/16"]

web_subnet_name    = "websubnet"
web_subnet_address = ["10.0.1.0/24"]

app_subnet_name    = "appsubnet"
app_subnet_address = ["10.0.11.0/24"]

db_subnet_name    = "dbsubnet"
db_subnet_address = ["10.0.21.0/24"]

bastion_subnet_name    = "bastionsubnet"
bastion_subnet_address = ["10.0.100.0/24"]

The terraform.tfvars file is used to define input variables for your Terraform configuration, allowing for the parameterization of resources and environments. In this file, key details such as the business unit, environment, resource group name, and location are specified, along with the names and address spaces for the virtual network and its associated subnets. This setup promotes flexibility and reusability, making it easier to manage infrastructure deployments across different environments. By separating variable values from the main configuration, it enhances maintainability and clarity in your Terraform projects.

Output Values

The defined outputs in this Terraform configuration provide essential information about the created resources, enhancing visibility and usability in subsequent deployments or integrations. For the virtual network, the outputs include its name and unique ID, which are crucial for referencing the VNet in other resources or configurations. Similarly, the outputs for the Web Subnet and its associated Network Security Group (NSG) ensure that the subnet name and ID, as well as the NSG name and ID, are readily accessible. This structured approach to defining outputs facilitates easy access to key attributes, enabling seamless interactions with the Terraform state and enhancing the overall management of the deployed infrastructure.

# Vnet required outputs

output "virtual_network_name" {
  value       = azurerm_virtual_network.vnet.name
  description = "Virtual network name"

}
output "virtual_network_id" {
  value       = azurerm_virtual_network.vnet.id
  description = "Virtual network id"

}

# Web Subnet outputs
output "web_subnet_name" {
  value       = azurerm_subnet.web_subnet.name
  description = "Web subnet name"

}
output "web_subnet_id" {
  value       = azurerm_subnet.web_subnet.id
  description = "Web subnet id"

}

# web NSG outputs
output "web_nsg_name" {
  value       = azurerm_network_security_group.web_snet_nsg.name
  description = "Web NSG name"

}
output "web_nsg_id" {
  value       = azurerm_network_security_group.web_snet_nsg.id
  description = "Web NSG id"

}

Resources on Azure Portal

After applying the Terraform configuration, a unique resource group was successfully created, containing the desired resources such as a virtual network (VNet), network security groups (NSGs), and subnets.

Additionally, the NSGs were correctly associated with their respective subnets, ensuring proper traffic control and security within the deployed infrastructure.

1
Subscribe to my newsletter

Read articles from Ritesh Kumar Nayak directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Ritesh Kumar Nayak
Ritesh Kumar Nayak

Passionate about helping organizations build scalable infrastructure and DevOps solutions with cloud technologies. Experienced in designing robust systems, automating processes, and driving efficiency through innovative cloud solutions. Advocate for best practices in DevOps and cloud computing, committed to enabling teams to achieve their full potential.