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:
azurerm_subnet
: To create subnets for each tier (Web, App, DB, Bastion).azurerm_network_security_group
: To create the NSGs for each subnet.azurerm_network_security_rule
: To define security rules (both inbound and outbound) within the NSGs.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
}
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.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.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.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.Creating NSG Inbound Rules: With the
azurerm_network_security_rule
resource, we iterate over theweb_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
}
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 usingazurerm_network_security_group
to secure application traffic.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.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.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.
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.