Multi-Environment Azure Infra with Terraform
Build reusable Terraform modules to deploy dev and prod Azure infrastructure.
Introduction
30 Second Summary
When a company runs the same application in testing and production, the infrastructure behind each environment needs to be identical but isolated. One typo in a production network config can take down an entire service, so teams need a way to guarantee consistency across environments.
In this project, you will build a multi-environment Azure infrastructure using reusable Terraform modules. You will deploy identical networking and compute resources to both a dev and prod environment, all managed by a remote state backend in Azure Blob Storage.
What You'll Build
A production-grade modular Terraform project that deploys a Virtual Network, Network Security Groups with dynamic rules, and a Linux VM to both dev and prod environments from a single set of shared modules.
By the end of this project, you'll have:
- Reusable networking and compute modules using for_each, dynamic blocks, and variable validation.
- A remote state backend in Azure Blob Storage with native blob lease locking and per-environment state isolation.
- Two fully deployed environments (dev and prod) using directory-based isolation with .tfvars and .tfbackend files.
- Secret Mission: Add a Key Vault module that stores the VM admin password as a secret and wire it into the compute module.
Are there any prerequisites?
This project assumes familiarity with Terraform basics (providers, resources, variables, outputs, state) and Azure fundamentals (resource groups, VNets, VMs). You should already have the Terraform CLI, Azure CLI, and an active Azure subscription configured on your machine.
Set Up the Remote State Backend
When multiple environments share the same Terraform modules, each environment needs its own state file stored in a central, locked location.
In this step, you will create an Azure Blob Storage backend that holds your Terraform state remotely. Each environment (dev and prod) gets its own state key inside the same storage container, giving you per-environment isolation with a single backend.
In this step, get ready to:
- Create an Azure Storage Account and blob container for Terraform state files.
- Configure per-environment .tfbackend files for dev and prod.
- Verify the state backend is accessible.
Create the state backend infrastructure
Terraform needs somewhere to persist its state file between runs. Storing state in Azure Blob Storage gives your team a single source of truth with automatic blob lease locking to prevent concurrent writes.
- Create a dedicated resource group for the state backend by running this command:
az group create --name rg-tfstate --location eastus
What does this command do?
This creates a resource group named rg-tfstate in the East US region. You keep state resources in their own resource group so they are separate from the infrastructure they manage.
You should see JSON output with "provisioningState": "Succeeded" confirming the resource group was created.
Seeing an authorization error?
Make sure you are logged in with az login and that your account has Contributor or Owner permissions on the subscription.
Help me fix my Azure CLI authorization error
Storage account names must be globally unique across all of Azure. They can only contain lowercase letters and numbers, between 3 and 24 characters.
- Create the storage account by running this command (replace the storage account name with your own unique name):
az storage account create \
--name [[STORAGE_ACCOUNT="your unique storage account name e.g. tfstate12345"]] \
--resource-group rg-tfstate \
--sku Standard_LRS \
--encryption-services blob \
--min-tls-version TLS1_2
What does this command do?
- Creates a storage account with Standard locally-redundant storage (cheapest tier).
- Enables blob encryption for data at rest.
- Enforces TLS 1.2 minimum for all connections.
You should see a large JSON response with "provisioningState": "Succeeded" near the bottom.
Name already taken?
If you see an error about the storage account name already existing, pick a different name. Try appending random digits or your initials to make it unique.
Help me pick a valid Azure storage account name
- Create the blob container that will hold your state files by running this command:
az storage container create \
--name tfstate \
--account-name [[STORAGE_ACCOUNT="your unique storage account name e.g. tfstate12345"]]
What does this command do?
This creates a blob container named tfstate inside your storage account. Both dev and prod state files will live in this single container, differentiated by their key paths.
You should see "created": true in the output.
Why Azure Blob Storage instead of a database?
Azure Blob Storage provides native blob lease locking. When Terraform writes to state, it acquires a lease on the blob that prevents any other process from writing simultaneously.
Unlike AWS (which requires a separate DynamoDB table for locking), Azure handles both storage and locking in one service. No extra resources needed.
Configure the .tfbackend files
Terraform's backend block cannot reference variables or data sources. The .tfbackend file pattern solves this by storing backend values externally and passing them to terraform init at runtime.
- Create the dev backend configuration file by adding this content to environments/dev/dev.tfbackend:
resource_group_name = "rg-tfstate"
storage_account_name = "[[STORAGE_ACCOUNT=\"your unique storage account name e.g. tfstate12345\"]]"
container_name = "tfstate"
key = "dev/terraform.tfstate"
What does this file do?
- Points Terraform to the storage account and container you just created.
- The key field sets the blob path within the container. Dev state lives at dev/terraform.tfstate.
- When you run terraform init -backend-config=dev.tfbackend later, these values populate the empty backend block in your providers file.
- Save dev.tfbackend.
- Create the prod backend configuration file by adding this content to environments/prod/prod.tfbackend:
resource_group_name = "rg-tfstate"
storage_account_name = "[[STORAGE_ACCOUNT=\"your unique storage account name e.g. tfstate12345\"]]"
container_name = "tfstate"
key = "prod/terraform.tfstate"
Why separate state keys?
Dev and prod each get their own blob path (dev/terraform.tfstate vs prod/terraform.tfstate). This means a terraform destroy in dev can never accidentally touch prod resources. The blast radius of every command stays within its own environment.
- Save prod.tfbackend.
Verify the state backend is accessible
- Confirm the blob container exists and is reachable by running this command:
az storage container show \
--name tfstate \
--account-name [[STORAGE_ACCOUNT="your unique storage account name e.g. tfstate12345"]] \
--query "name" \
--output tsv
You should see tfstate printed to your terminal. This confirms your backend infrastructure is ready for Terraform to use.
Seeing a container not found error?
Double check that the --account-name value matches the exact storage account name you created earlier. Storage account names are case-sensitive and must be all lowercase.
Help me troubleshoot my Azure storage container access
- Verify your .tfbackend files are in the correct locations by running:
ls environments/dev/dev.tfbackend environments/prod/prod.tfbackend
You should see both file paths listed without any errors.
✔️ Awesome, I've got everything!
Great. Make sure both .tfbackend files are saved. You now have a fully provisioned remote state backend.
ⓧ I'd like to double check the full code
Here is the full content of each file:
resource_group_name = "rg-tfstate"
storage_account_name = "[[STORAGE_ACCOUNT=\"your unique storage account name e.g. tfstate12345\"]]"
container_name = "tfstate"
key = "dev/terraform.tfstate"
resource_group_name = "rg-tfstate"
storage_account_name = "[[STORAGE_ACCOUNT=\"your unique storage account name e.g. tfstate12345\"]]"
container_name = "tfstate"
key = "prod/terraform.tfstate"
Your remote state backend is ready. Next up, you will build the networking module that provisions VNets, subnets, and security groups from a single reusable source.
Build the Networking Module
Your remote state backend is ready to track infrastructure changes. Now it's time to build the first reusable building block: a networking module that creates a Virtual Network, segments it into subnets, and locks it down with security rules.
A Terraform module is a self-contained package of .tf files that accepts inputs and produces outputs. By isolating networking logic into its own module, you can reuse it across dev, prod, and any future environment without duplicating code.
In this step, get ready to:
- Create the networking module's directory structure with variables.tf, main.tf, and outputs.tf.
- Implement the VNet, subnets (using for_each), and NSG (using dynamic blocks) in main.tf.
- Add CIDR validation to variables and expose subnet IDs and NSG ID as outputs.
Create the module directory and define variables
Every Terraform module follows a standard file layout: variables.tf for inputs, main.tf for resources, and outputs.tf for values other modules can consume.
- Create the networking module directory and its three files by running these commands:
mkdir -p modules/networking
touch modules/networking/variables.tf
touch modules/networking/main.tf
touch modules/networking/outputs.tf
- Confirm the files exist by running:
ls modules/networking/
You should see main.tf, outputs.tf, and variables.tf listed in the output.
Don't see all three files?
Make sure you ran the commands from your project root directory (the folder that contains the modules/ and environments/ folders). Run pwd to check your current location.
Help me troubleshoot my directory structure
The networking module needs to accept several inputs: a resource group name, a location, VNet configuration, subnet definitions, and security rules. You will define these using complex variable types that give callers full flexibility.
- Open modules/networking/variables.tf and paste the following variable definitions:
variable "resource_group_name" {
description = "Name of the resource group"
type = string
}
variable "location" {
description = "Azure region for resources"
type = string
}
variable "vnet_name" {
description = "Name of the virtual network"
type = string
}
# Address space accepts a list so VNets can span multiple ranges
variable "vnet_address_space" {
description = "Address space for the VNet"
type = list(string)
}
# map(object) lets callers define any number of subnets by name
variable "subnets" {
description = "Map of subnet configurations"
type = map(object({
address_prefixes = list(string)
}))
}
# Each rule is a full NSG rule definition; defaults to empty (no rules)
variable "nsg_rules" {
description = "List of NSG rules to create"
type = list(object({
name = string
priority = number
direction = string
access = string
protocol = string
source_port_range = string
destination_port_range = string
source_address_prefix = string
destination_address_prefix = string
}))
default = []
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
What do these variable types mean?
The subnets variable uses map(object({...})). This means callers pass a map where each key is a subnet name and each value is an object with an address_prefixes list.
The nsg_rules variable uses list(object({...})) to accept any number of security rule definitions. Defaulting to an empty list means the module works even if no rules are passed.
- Save variables.tf.
Implement the networking resources
The networking module provisions three Azure resource types: a Virtual Network, subnets within it, and a Network Security Group that applies firewall rules to every subnet.
- Open modules/networking/main.tf and paste the VNet and subnet resources:
resource "azurerm_virtual_network" "this" {
name = var.vnet_name
address_space = var.vnet_address_space
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
}
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = each.key
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = each.value.address_prefixes
}
What does for_each do here?
The for_each = var.subnets line tells Terraform to create one azurerm_subnet resource for every entry in the subnets map. The map key becomes the subnet name (each.key) and the value provides configuration (each.value.address_prefixes).
This means callers can define 1 subnet or 10 subnets without changing the module code at all.
You won't see a runtime change yet. The next block adds the security group that completes the module.
- Below the subnet resource in main.tf, paste the NSG and its subnet association:
resource "azurerm_network_security_group" "this" {
name = "${var.vnet_name}-nsg"
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
dynamic "security_rule" {
for_each = var.nsg_rules
content {
name = security_rule.value.name
priority = security_rule.value.priority
direction = security_rule.value.direction
access = security_rule.value.access
protocol = security_rule.value.protocol
source_port_range = security_rule.value.source_port_range
destination_port_range = security_rule.value.destination_port_range
source_address_prefix = security_rule.value.source_address_prefix
destination_address_prefix = security_rule.value.destination_address_prefix
}
}
}
resource "azurerm_subnet_network_security_group_association" "this" {
for_each = azurerm_subnet.this
subnet_id = each.value.id
network_security_group_id = azurerm_network_security_group.this.id
}
What does the dynamic block do?
A dynamic block generates repeating nested blocks inside a resource. Instead of hardcoding each security_rule block, Terraform iterates over var.nsg_rules and generates one rule block per list item.
The association resource uses for_each = azurerm_subnet.this to attach the NSG to every subnet the module creates. This is dependency inversion at work: the module wires its own internal relationships.
- Save main.tf.
- Verify your HCL syntax is valid by running:
terraform fmt modules/networking/
If the command prints a filename, it auto-formatted that file. If it prints nothing, your formatting is already correct.
Seeing a syntax error?
The terraform fmt command fails if there are unclosed braces or mismatched quotes. Check that every opening { has a matching } and that the dynamic block's content block is fully closed.
Help me fix a syntax error in my networking main.tf
✔️ Awesome, I've got everything!
Great. Make sure you saved main.tf before moving on.
ⓧ I'd like to double check the full code
resource "azurerm_virtual_network" "this" {
name = var.vnet_name
address_space = var.vnet_address_space
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
}
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = each.key
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = each.value.address_prefixes
}
resource "azurerm_network_security_group" "this" {
name = "${var.vnet_name}-nsg"
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
dynamic "security_rule" {
for_each = var.nsg_rules
content {
name = security_rule.value.name
priority = security_rule.value.priority
direction = security_rule.value.direction
access = security_rule.value.access
protocol = security_rule.value.protocol
source_port_range = security_rule.value.source_port_range
destination_port_range = security_rule.value.destination_port_range
source_address_prefix = security_rule.value.source_address_prefix
destination_address_prefix = security_rule.value.destination_address_prefix
}
}
}
resource "azurerm_subnet_network_security_group_association" "this" {
for_each = azurerm_subnet.this
subnet_id = each.value.id
network_security_group_id = azurerm_network_security_group.this.id
}
Add variable validation and expose outputs
Terraform's validation blocks let you catch invalid inputs before Terraform even talks to Azure. You will add a CIDR validation to vnet_address_space so that callers cannot pass malformed network ranges.
- In modules/networking/variables.tf, find the vnet_address_space variable. It currently looks like this:
variable "vnet_address_space" {
description = "Address space for the VNet"
type = list(string)
}
- Replace the entire vnet_address_space variable block with this version that includes a validation rule:
variable "vnet_address_space" {
description = "Address space for the VNet"
type = list(string)
validation {
condition = alltrue([for cidr in var.vnet_address_space : can(cidrhost(cidr, 0))])
error_message = "Each item in vnet_address_space must be a valid CIDR block."
}
}
How does this validation work?
The condition uses can(cidrhost(cidr, 0)) to test whether each string is a parseable CIDR block. If any value fails (like "not-a-cidr"), Terraform rejects the plan immediately with a clear error message.
- Save variables.tf.
The last file to write is outputs.tf. Outputs are how one module passes values to another. The root module will need subnet IDs and the NSG ID to wire into the compute module later.
- Open modules/networking/outputs.tf and paste the following:
output "vnet_id" {
description = "ID of the virtual network"
value = azurerm_virtual_network.this.id
}
output "vnet_name" {
description = "Name of the virtual network"
value = azurerm_virtual_network.this.name
}
# Transforms the for_each subnet map into a simple name-to-ID lookup
output "subnet_ids" {
description = "Map of subnet names to their IDs"
value = { for k, v in azurerm_subnet.this : k => v.id }
}
output "nsg_id" {
description = "ID of the network security group"
value = azurerm_network_security_group.this.id
}
What does the subnet_ids output do?
The expression { for k, v in azurerm_subnet.this : k => v.id } loops over every subnet created by for_each and builds a map like { web = "/subscriptions/.../web", app = "/subscriptions/.../app" }. Consumers can then reference a specific subnet by name.
- Save outputs.tf.
- Run a final format check to confirm all three files are valid:
terraform fmt modules/networking/
No output means all files are properly formatted and syntactically valid.
Seeing an error about missing closing brace?
The validation block adds an extra level of nesting inside the variable block. Make sure you have a closing } for the validation {} block AND a separate closing } for the variable {} block.
Help me fix a formatting error in my variables.tf
✔️ Awesome, I've got everything!
All three module files are complete. You are ready to build the compute module next.
ⓧ I'd like to double check the full code
Here is the complete code for all three files in the networking module.
variable "resource_group_name" {
description = "Name of the resource group"
type = string
}
variable "location" {
description = "Azure region for resources"
type = string
}
variable "vnet_name" {
description = "Name of the virtual network"
type = string
}
variable "vnet_address_space" {
description = "Address space for the VNet"
type = list(string)
validation {
condition = alltrue([for cidr in var.vnet_address_space : can(cidrhost(cidr, 0))])
error_message = "Each item in vnet_address_space must be a valid CIDR block."
}
}
variable "subnets" {
description = "Map of subnet configurations"
type = map(object({
address_prefixes = list(string)
}))
}
variable "nsg_rules" {
description = "List of NSG rules to create"
type = list(object({
name = string
priority = number
direction = string
access = string
protocol = string
source_port_range = string
destination_port_range = string
source_address_prefix = string
destination_address_prefix = string
}))
default = []
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
resource "azurerm_virtual_network" "this" {
name = var.vnet_name
address_space = var.vnet_address_space
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
}
resource "azurerm_subnet" "this" {
for_each = var.subnets
name = each.key
resource_group_name = var.resource_group_name
virtual_network_name = azurerm_virtual_network.this.name
address_prefixes = each.value.address_prefixes
}
resource "azurerm_network_security_group" "this" {
name = "${var.vnet_name}-nsg"
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
dynamic "security_rule" {
for_each = var.nsg_rules
content {
name = security_rule.value.name
priority = security_rule.value.priority
direction = security_rule.value.direction
access = security_rule.value.access
protocol = security_rule.value.protocol
source_port_range = security_rule.value.source_port_range
destination_port_range = security_rule.value.destination_port_range
source_address_prefix = security_rule.value.source_address_prefix
destination_address_prefix = security_rule.value.destination_address_prefix
}
}
}
resource "azurerm_subnet_network_security_group_association" "this" {
for_each = azurerm_subnet.this
subnet_id = each.value.id
network_security_group_id = azurerm_network_security_group.this.id
}
output "vnet_id" {
description = "ID of the virtual network"
value = azurerm_virtual_network.this.id
}
output "vnet_name" {
description = "Name of the virtual network"
value = azurerm_virtual_network.this.name
}
output "subnet_ids" {
description = "Map of subnet names to their IDs"
value = { for k, v in azurerm_subnet.this : k => v.id }
}
output "nsg_id" {
description = "ID of the network security group"
value = azurerm_network_security_group.this.id
}
Your networking module is complete. It can create any number of subnets and apply any number of security rules, all driven by the variables callers pass in. Next up, you will build the compute module that places a Linux VM inside one of these subnets.
Build the Compute Module
Your networking module handles the virtual network, subnets, and security rules. Now you need a compute module that provisions the actual server that lives inside that network.
The key design principle here is dependency inversion. Instead of the compute module looking up its own subnet, it receives the subnet ID as an input from the caller. This makes the module reusable across any subnet in any environment.
In this step, get ready to:
- Create the compute module with a public IP, network interface, and Linux VM.
- Wire the module inputs to accept subnet_id from the networking module.
- Add variable validation for VM size and expose outputs.
Create the compute module resources
The compute module provisions three resources that work together: a static public IP, a network interface that attaches to a subnet, and a Linux virtual machine that uses the NIC.
- Create a new file called main.tf inside your modules/compute directory.
- Add the public IP and network interface resources by pasting this code:
resource "azurerm_public_ip" "this" {
name = "${var.vm_name}-pip"
location = var.location
resource_group_name = var.resource_group_name
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
resource "azurerm_network_interface" "this" {
name = "${var.vm_name}-nic"
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
ip_configuration {
name = "internal"
subnet_id = var.subnet_id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.this.id
}
}
What does this code do?
The public IP uses a Static allocation with the Standard SKU. This ensures the IP address persists across VM restarts.
The network interface's ip_configuration block wires three things together: the subnet (from the networking module), a dynamic private IP within that subnet, and the static public IP for external access.
- Below the network interface resource, add the Linux virtual machine resource:
resource "azurerm_linux_virtual_machine" "this" {
name = var.vm_name
resource_group_name = var.resource_group_name
location = var.location
size = var.vm_size
admin_username = var.admin_username
admin_password = var.admin_password
disable_password_authentication = false
network_interface_ids = [azurerm_network_interface.this.id]
tags = var.tags
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
}
What does this code do?
This defines a Linux VM running Ubuntu 22.04 LTS (Gen2). The disable_password_authentication = false setting enables password-based SSH login. In production you would use SSH keys, but passwords simplify this learning project.
The os_disk block uses Standard_LRS (locally redundant storage) for cost efficiency. The source_image_reference block points to Canonical's official Ubuntu Server Jammy image.
- Save modules/compute/main.tf.
Seeing syntax highlighting errors?
Make sure you have both the opening and closing braces for every nested block (ip_configuration, os_disk, source_image_reference). A missing } is the most common cause of HCL parse errors.
Help me fix a syntax error in my Terraform compute module main.tf
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
resource "azurerm_public_ip" "this" {
name = "${var.vm_name}-pip"
location = var.location
resource_group_name = var.resource_group_name
allocation_method = "Static"
sku = "Standard"
tags = var.tags
}
resource "azurerm_network_interface" "this" {
name = "${var.vm_name}-nic"
location = var.location
resource_group_name = var.resource_group_name
tags = var.tags
ip_configuration {
name = "internal"
subnet_id = var.subnet_id
private_ip_address_allocation = "Dynamic"
public_ip_address_id = azurerm_public_ip.this.id
}
}
resource "azurerm_linux_virtual_machine" "this" {
name = var.vm_name
resource_group_name = var.resource_group_name
location = var.location
size = var.vm_size
admin_username = var.admin_username
admin_password = var.admin_password
disable_password_authentication = false
network_interface_ids = [azurerm_network_interface.this.id]
tags = var.tags
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
source_image_reference {
publisher = "Canonical"
offer = "0001-com-ubuntu-server-jammy"
sku = "22_04-lts-gen2"
version = "latest"
}
}
Define the module input variables
The compute module needs to receive its dependencies (like the subnet to deploy into) as input variables. This is dependency inversion: the module declares what it needs, and the caller supplies it.
- Create a new file called variables.tf inside modules/compute.
- Paste the following variable definitions:
variable "resource_group_name" {
description = "Name of the resource group"
type = string
}
variable "location" {
description = "Azure region for resources"
type = string
}
variable "vm_name" {
description = "Name of the virtual machine"
type = string
}
variable "vm_size" {
description = "Size of the virtual machine"
type = string
}
variable "subnet_id" {
description = "ID of the subnet to attach the VM to"
type = string
}
variable "admin_username" {
description = "Admin username for the VM"
type = string
default = "azureuser"
}
variable "admin_password" {
description = "Admin password for the VM"
type = string
sensitive = true
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
Why is subnet_id an input instead of a data source lookup?
The compute module could use a data source to look up its subnet by name. But that would hard-couple the module to a specific naming scheme.
By accepting subnet_id as an input, the root module can pass in any subnet from any VNet. The networking module outputs subnet IDs, and the root module wires them into compute. Each module stays independent and reusable.
What does sensitive = true do?
The sensitive = true attribute on admin_password tells Terraform to redact the value from CLI output and plan logs. The password still exists in state, but it won't be displayed in the terminal.
- Save modules/compute/variables.tf.
- Confirm the file exists by running:
ls modules/compute/
You should see both main.tf and variables.tf listed.
Only seeing one file?
Make sure you saved both files to the modules/compute/ directory, not to modules/networking/ or the project root. Check the file path in your editor's tab or title bar.
Help me find where my Terraform file was saved
Add variable validation and expose outputs
Variable validation catches configuration errors before Terraform even tries to create resources. You will add a validation rule to vm_size that ensures only valid Azure VM sizes are accepted.
- In modules/compute/variables.tf, find the vm_size variable. It currently looks like this:
variable "vm_size" {
description = "Size of the virtual machine"
type = string
}
- Replace the entire vm_size variable block with this version that includes a validation rule:
variable "vm_size" {
description = "Size of the virtual machine"
type = string
validation {
condition = can(regex("^Standard_", var.vm_size))
error_message = "VM size must start with 'Standard_'."
}
}
What does this validation do?
The can(regex("^Standard_", var.vm_size)) condition checks that the value starts with Standard_. All valid Azure VM sizes follow this prefix pattern (like Standard_B1s or Standard_B2s). If someone passes an invalid value, Terraform fails immediately with a clear error message instead of attempting a deployment that would fail at the Azure API level.
- Save modules/compute/variables.tf.
✔️ Awesome, I've got everything!
Great. Double check you've saved the updated file before moving on.
ⓧ I'd like to double check the full code
variable "resource_group_name" {
description = "Name of the resource group"
type = string
}
variable "location" {
description = "Azure region for resources"
type = string
}
variable "vm_name" {
description = "Name of the virtual machine"
type = string
}
variable "vm_size" {
description = "Size of the virtual machine"
type = string
validation {
condition = can(regex("^Standard_", var.vm_size))
error_message = "VM size must start with 'Standard_'."
}
}
variable "subnet_id" {
description = "ID of the subnet to attach the VM to"
type = string
}
variable "admin_username" {
description = "Admin username for the VM"
type = string
default = "azureuser"
}
variable "admin_password" {
description = "Admin password for the VM"
type = string
sensitive = true
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}
Now you need an outputs file so that the root module can access the compute module's results (like the VM's public IP).
- Create a new file called outputs.tf inside modules/compute.
- Add the following output definitions:
output "vm_id" {
description = "ID of the virtual machine"
value = azurerm_linux_virtual_machine.this.id
}
output "public_ip_address" {
description = "Public IP address of the VM"
value = azurerm_public_ip.this.ip_address
}
output "private_ip_address" {
description = "Private IP address of the VM"
value = azurerm_network_interface.this.private_ip_address
}
What does this code do?
These outputs expose the VM's ID (for cross-referencing in other resources), the public IP (for SSH access), and the private IP (for internal network communication). The root module can reference these as module.compute.public_ip_address.
- Save modules/compute/outputs.tf.
- Verify all three compute module files exist by running:
ls modules/compute/
You should see main.tf, outputs.tf, and variables.tf listed.
Missing a file?
If any file is missing, check that you saved it to modules/compute/ and not the project root or the networking module directory. Your editor's file tree should show the compute folder with all three files.
Help me find my missing Terraform compute module file
✔️ Awesome, I've got everything!
Great. Your compute module is complete.
ⓧ I'd like to double check the full code
output "vm_id" {
description = "ID of the virtual machine"
value = azurerm_linux_virtual_machine.this.id
}
output "public_ip_address" {
description = "Public IP address of the VM"
value = azurerm_public_ip.this.ip_address
}
output "private_ip_address" {
description = "Private IP address of the VM"
value = azurerm_network_interface.this.private_ip_address
}
Both modules are ready. Next up, you will compose them together in the dev environment and deploy real infrastructure to Azure.
Compose Modules in the Dev Environment
You now have two fully functional Terraform modules that can provision networking and compute resources independently.
The real power of modules shows up when you compose them together. In this step, you will create a root configuration in the environments/dev directory that wires the networking module's outputs into the compute module's inputs. This is the flat composition pattern in action: one root module orchestrating child modules through explicit data flow.
In this step, get ready to:
- Create the dev environment configuration files (providers, variables, and tfvars).
- Wire the networking and compute modules together in main.tf using dependency inversion.
- Deploy the dev environment with terraform init, plan, and apply.
Create the dev environment provider and variable files
The root configuration in environments/dev needs its own provider setup, variable declarations, and environment-specific values. These files define the contract between your environment and the shared modules.
- Create a file called providers.tf inside the environments/dev directory, then add the following code:
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
backend "azurerm" {}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
What does this code do?
The backend "azurerm" {} block is intentionally empty. Terraform cannot reference variables or data sources inside backend blocks, so the actual values come from the .tfbackend file you created in Step 1.
The subscription_id is required in azurerm provider v4.0+. You pass it as a variable so different environments can target different subscriptions if needed.
- Save providers.tf.
- Now create a file called variables.tf in the same environments/dev directory, then add the following code:
variable "subscription_id" {
description = "Azure Subscription ID"
type = string
}
variable "location" {
description = "Azure region"
type = string
default = "eastus"
}
variable "environment" {
description = "Environment name (dev, prod)"
type = string
}
variable "vnet_address_space" {
description = "Address space for the VNet"
type = list(string)
}
variable "subnets" {
description = "Map of subnets"
type = map(object({
address_prefixes = list(string)
}))
}
variable "vm_size" {
description = "VM size for the compute instance"
type = string
}
variable "admin_password" {
description = "Admin password for the VM"
type = string
sensitive = true
}
What does this code do?
These variables act as the interface for the dev environment. Notice that subnets uses a complex object type map(object({...})). This matches the type expected by the networking module, so values flow through cleanly.
The admin_password is marked sensitive = true so Terraform redacts it from plan and apply output.
- Save variables.tf.
- Create a file called dev.tfvars in the same directory, then add the following code:
environment = "dev"
location = "eastus"
vnet_address_space = ["10.0.0.0/16"]
subnets = {
web = {
address_prefixes = ["10.0.1.0/24"]
}
app = {
address_prefixes = ["10.0.2.0/24"]
}
data = {
address_prefixes = ["10.0.3.0/24"]
}
}
vm_size = "Standard_B1s"
What does this code do?
This file holds all dev-specific values. The 10.0.0.0/16 address space gives you room for 65,536 addresses, segmented into three /24 subnets: web, app, and data.
Standard_B1s is a burstable VM at roughly $0.012/hr. Perfect for dev workloads where you do not need sustained CPU performance.
- Save dev.tfvars.
- Confirm all three files exist by running:
ls environments/dev/
You should see dev.tfbackend, dev.tfvars, providers.tf, and variables.tf in the output.
Missing a file?
Make sure you created each file inside environments/dev/ and not in the project root or the modules directory.
help me find where my Terraform dev environment files ended up
Wire the networking and compute modules together
The root main.tf is where composition happens. You will create a resource group, call both modules, and pass the networking module's subnet ID output into the compute module's input. This is dependency inversion: modules do not look up their own dependencies. The root injects them.
- Create a file called main.tf inside environments/dev. Start by adding the resource group and the networking module block:
resource "azurerm_resource_group" "this" {
name = "rg-${var.environment}-infra"
location = var.location
tags = {
environment = var.environment
managed_by = "terraform"
}
}
module "networking" {
source = "../../modules/networking"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
vnet_name = "vnet-${var.environment}"
vnet_address_space = var.vnet_address_space
subnets = var.subnets
nsg_rules = [
{
name = "allow-ssh"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
},
{
name = "allow-http"
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
]
tags = {
environment = var.environment
managed_by = "terraform"
}
}
What does this code do?
The resource group rg-${var.environment}-infra resolves to rg-dev-infra at plan time. All resources in this environment live inside it.
The networking module receives its resource group and location from the resource group resource itself (not from variables). This creates an explicit dependency graph so Terraform knows the resource group must exist before the VNet.
The nsg_rules list defines two inbound rules for SSH (port 22) and HTTP (port 80). These get rendered as dynamic blocks inside the NSG resource.
- Now add the compute module block below the networking module in the same main.tf file:
module "compute" {
source = "../../modules/compute"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
vm_name = "vm-${var.environment}-web"
vm_size = var.vm_size
subnet_id = module.networking.subnet_ids["web"]
admin_password = var.admin_password
tags = {
environment = var.environment
managed_by = "terraform"
}
}
What does this code do?
This is the key wiring line: subnet_id = module.networking.subnet_ids["web"]. The compute module does not know how to find a subnet. It receives one from the root. This is dependency inversion.
Because the compute module references the networking module's output, Terraform automatically builds a dependency graph. Networking provisions first, then compute.
- Save main.tf.
- Create one more file called outputs.tf in environments/dev, then add the following code:
output "resource_group_name" {
value = azurerm_resource_group.this.name
}
output "vnet_id" {
value = module.networking.vnet_id
}
output "vm_public_ip" {
value = module.compute.public_ip_address
}
output "vm_private_ip" {
value = module.compute.private_ip_address
}
What does this code do?
Root outputs surface useful values after terraform apply completes. You will see the VM's public IP printed to the terminal, which you can use to SSH into the machine.
- Save outputs.tf.
✔️ Awesome, I've got everything!
Make sure you have saved all five files in environments/dev: providers.tf, variables.tf, main.tf, outputs.tf, and dev.tfvars.
ⓧ I'd like to double check the full code
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
backend "azurerm" {}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
variable "subscription_id" {
description = "Azure Subscription ID"
type = string
}
variable "location" {
description = "Azure region"
type = string
default = "eastus"
}
variable "environment" {
description = "Environment name (dev, prod)"
type = string
}
variable "vnet_address_space" {
description = "Address space for the VNet"
type = list(string)
}
variable "subnets" {
description = "Map of subnets"
type = map(object({
address_prefixes = list(string)
}))
}
variable "vm_size" {
description = "VM size for the compute instance"
type = string
}
variable "admin_password" {
description = "Admin password for the VM"
type = string
sensitive = true
}
resource "azurerm_resource_group" "this" {
name = "rg-${var.environment}-infra"
location = var.location
tags = {
environment = var.environment
managed_by = "terraform"
}
}
module "networking" {
source = "../../modules/networking"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
vnet_name = "vnet-${var.environment}"
vnet_address_space = var.vnet_address_space
subnets = var.subnets
nsg_rules = [
{
name = "allow-ssh"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
},
{
name = "allow-http"
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
]
tags = {
environment = var.environment
managed_by = "terraform"
}
}
module "compute" {
source = "../../modules/compute"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
vm_name = "vm-${var.environment}-web"
vm_size = var.vm_size
subnet_id = module.networking.subnet_ids["web"]
admin_password = var.admin_password
tags = {
environment = var.environment
managed_by = "terraform"
}
}
output "resource_group_name" {
value = azurerm_resource_group.this.name
}
output "vnet_id" {
value = module.networking.vnet_id
}
output "vm_public_ip" {
value = module.compute.public_ip_address
}
output "vm_private_ip" {
value = module.compute.private_ip_address
}
environment = "dev"
location = "eastus"
vnet_address_space = ["10.0.0.0/16"]
subnets = {
web = {
address_prefixes = ["10.0.1.0/24"]
}
app = {
address_prefixes = ["10.0.2.0/24"]
}
data = {
address_prefixes = ["10.0.3.0/24"]
}
}
vm_size = "Standard_B1s"
Deploy the dev environment
With all configuration files in place, you can now initialize Terraform against the remote backend and deploy the full dev environment.
- Navigate into the environments/dev directory by running:
cd environments/dev
- Initialize Terraform using the dev.tfbackend file to connect to the remote state backend by running:
terraform init -backend-config=dev.tfbackend
What does this command do?
The -backend-config flag populates the empty backend "azurerm" {} block in providers.tf with the storage account, container, and state key from your .tfbackend file.
Terraform authenticates to Azure Blob Storage using your Azure CLI credentials from az login. No extra authentication setup is needed.
You should see output ending with Terraform has been successfully initialized! along with a message confirming the azurerm backend is configured.
Seeing an authentication error?
Make sure your Azure CLI session is active. Run az account show to verify you are logged in. If the session expired, run az login again.
Also check that the storage account name in your dev.tfbackend matches exactly what you created in Step 1. Storage account names are globally unique and case-sensitive.
help me debug my Terraform init backend error
- Preview the infrastructure changes by running:
terraform plan -var-file=dev.tfvars -var="subscription_id=your-subscription-id" -var="admin_password=your-secure-password-here"
What does this command do?
The -var-file=dev.tfvars loads the environment-specific values you defined. The -var flags pass sensitive values that should not live in a file. Replace your-subscription-id with your Azure subscription ID and your-secure-password-here with a password of your choice (at least 12 characters, with uppercase, lowercase, and a number).
Find your subscription ID
If you do not know your subscription ID, run az account show --query id -o tsv in a separate terminal tab to retrieve it.
The plan output should show approximately 9 resources to add (resource group, VNet, 3 subnets, NSG, 3 subnet-NSG associations, public IP, NIC, and VM). Review the plan to confirm it looks correct.
- Deploy the dev environment by running:
terraform apply -var-file=dev.tfvars -var="subscription_id=your-subscription-id" -var="admin_password=your-secure-password-here"
- When Terraform prompts Do you want to perform these actions?, type yes and press **Enter**.
Terraform will take 2-4 minutes to provision all resources. When it completes, you will see the outputs printed to your terminal including vm_public_ip with an IP address.
Seeing a quota or capacity error?
Some Azure subscriptions have VM quotas. If you see a message about insufficient capacity or quota limits, try changing the region in dev.tfvars from eastus to westus2 or centralus, then run the apply again.
help me resolve my Azure VM deployment error
- Verify the state is stored remotely by running:
terraform state list
You should see resources listed including azurerm_resource_group.this, module.networking.azurerm_virtual_network.this, and module.compute.azurerm_linux_virtual_machine.this. This confirms Terraform is reading state from your remote backend.
Your dev environment is live. A full VNet with three subnets, a security group, and a Linux VM are all running in Azure, deployed from your reusable modules. Next, you will replicate this for production and prove that both environments are truly isolated.
Deploy the Prod Environment and Verify Isolation
Your dev environment is live and managed by its own isolated state file. The real power of directory-based environment isolation comes when you deploy a second environment from the same modules.
In this step, you will create the prod environment configuration with production-appropriate values, deploy it, and confirm that both environments are fully independent.
In this step, get ready to:
- Create the prod configuration files mirroring dev with production-specific values.
- Deploy the prod environment using its own backend config.
- Verify state isolation between dev and prod.
Create the prod environment configuration
The prod environment uses the exact same modules as dev. The only differences are in the variable values and the NSG rules.
Three files are identical to their dev counterparts: providers.tf, variables.tf, and outputs.tf.
- Create environments/prod/providers.tf and paste the following code:
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
backend "azurerm" {}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
What does this code do?
This is identical to the dev provider config. The empty backend "azurerm" {} block gets its values from the prod.tfbackend file you created in Step 1.
The subscription_id variable ensures the provider targets the correct Azure subscription.
- Create environments/prod/variables.tf and paste the following code:
variable "subscription_id" {
description = "Azure Subscription ID"
type = string
}
variable "location" {
description = "Azure region"
type = string
default = "eastus"
}
variable "environment" {
description = "Environment name (dev, prod)"
type = string
}
variable "vnet_address_space" {
description = "Address space for the VNet"
type = list(string)
}
variable "subnets" {
description = "Map of subnets"
type = map(object({
address_prefixes = list(string)
}))
}
variable "vm_size" {
description = "VM size for the compute instance"
type = string
}
variable "admin_password" {
description = "Admin password for the VM"
type = string
sensitive = true
}
What does this code do?
The variable declarations are identical to dev. Both environments accept the same inputs. The difference is in the values you pass via .tfvars files.
- Create environments/prod/outputs.tf and paste the following code:
output "resource_group_name" {
value = azurerm_resource_group.this.name
}
output "vnet_id" {
value = module.networking.vnet_id
}
output "vm_public_ip" {
value = module.compute.public_ip_address
}
output "vm_private_ip" {
value = module.compute.private_ip_address
}
What does this code do?
These outputs surface the key resource identifiers after deployment. They are the same outputs as dev because both environments produce the same resource types.
- Save all three files.
Now for the two files that differentiate prod from dev.
- Create environments/prod/main.tf and paste the following code:
resource "azurerm_resource_group" "this" {
name = "rg-${var.environment}-infra"
location = var.location
tags = {
environment = var.environment
managed_by = "terraform"
}
}
module "networking" {
source = "../../modules/networking"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
vnet_name = "vnet-${var.environment}"
vnet_address_space = var.vnet_address_space
subnets = var.subnets
nsg_rules = [
{
name = "allow-ssh"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
},
{
name = "allow-http"
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
},
{
name = "allow-https"
priority = 300
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
]
tags = {
environment = var.environment
managed_by = "terraform"
}
}
module "compute" {
source = "../../modules/compute"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
vm_name = "vm-${var.environment}-web"
vm_size = var.vm_size
subnet_id = module.networking.subnet_ids["web"]
admin_password = var.admin_password
tags = {
environment = var.environment
managed_by = "terraform"
}
}
What's different from dev?
The module calls and resource group are structurally identical. The key difference is that prod's nsg_rules list includes a third rule: allow-https on port 443 with priority 300.
In a real production environment, you would expose HTTPS for secure traffic. Dev environments typically skip this because they use self-signed certificates or local testing.
- Save main.tf.
- Create environments/prod/prod.tfvars and paste the following code:
environment = "prod"
location = "eastus"
vnet_address_space = ["10.1.0.0/16"]
subnets = {
web = {
address_prefixes = ["10.1.1.0/24"]
}
app = {
address_prefixes = ["10.1.2.0/24"]
}
data = {
address_prefixes = ["10.1.3.0/24"]
}
}
vm_size = "Standard_B2s"
What's different from dev.tfvars?
Three differences from the dev values:
- The VNet uses 10.1.0.0/16 instead of 10.0.0.0/16. This prevents address conflicts if you ever peer the two networks.
- Subnets follow the same pattern but in the 10.1.x.0/24 range.
- The VM size is Standard_B2s (2 vCPUs, 4 GB RAM) instead of Standard_B1s (1 vCPU, 1 GB RAM). Production workloads need more resources.
- Save prod.tfvars.
Files not showing in the right place?
Make sure all five new files are inside the environments/prod/ directory alongside the existing prod.tfbackend file from Step 1.
help me check my project directory structure is correct
Deploy the prod environment
The deployment workflow for prod is identical to dev: init with backend config, plan, then apply. Each environment is fully self-contained.
- Navigate to the prod environment directory by running:
cd environments/prod
- Initialize Terraform with the prod backend config by running:
terraform init -backend-config=prod.tfbackend
What does this command do?
The -backend-config=prod.tfbackend flag populates the empty backend "azurerm" {} block with prod-specific values. The state key is prod/terraform.tfstate, completely separate from dev's dev/terraform.tfstate.
- You should see Terraform has been successfully initialized! in the output.
- Run the plan to preview what Terraform will create:
terraform plan -var-file=prod.tfvars -var="subscription_id=your-subscription-id" -var="admin_password=your-secure-password-here"
What should I see?
The plan output should show resources to add. You will see the resource group, VNet, three subnets, an NSG, three NSG associations, a public IP, a NIC, and a VM. The plan should show approximately 10 resources to create.
Use the same subscription ID from dev
Replace your-subscription-id with the same Azure Subscription ID you used for the dev deployment. Replace your-secure-password-here with a strong password (at least 12 characters with uppercase, lowercase, numbers, and special characters).
- Apply the configuration to deploy the prod environment:
terraform apply -var-file=prod.tfvars -var="subscription_id=your-subscription-id" -var="admin_password=your-secure-password-here"
- When prompted, type yes and press Enter to confirm.
The deployment takes 2-3 minutes. When it completes, you will see the outputs including the VM public IP and private IP.
Seeing an authentication or subscription error?
Make sure your Azure CLI session is still active. Run az account show to verify. If the session expired, run az login again.
help me troubleshoot my Terraform Azure authentication error
Verify state isolation between environments
The entire point of directory-based isolation is that each environment has its own state file. Modifying prod must never affect dev, and vice versa.
- While still in the environments/prod directory, list all resources in prod's state by running:
terraform state list
What should I see?
You should see resources with prod in their names, including:
- azurerm_resource_group.this (which is rg-prod-infra).
- module.networking.azurerm_virtual_network.this (which is vnet-prod).
- module.compute.azurerm_linux_virtual_machine.this (which is vm-prod-web).
- Navigate to the dev environment directory by running:
cd ../dev
- List all resources in dev's state by running:
terraform state list
What should I see?
You should see the same resource structure but with dev names: rg-dev-infra, vnet-dev, vm-dev-web. No prod resources appear here.
Why does this matter?
Each state file tracks only its own environment's resources. Running terraform destroy in prod will never touch dev. Running terraform plan in dev will never show prod drift.
This is the fundamental guarantee of directory-based isolation. Workspaces share a single backend configuration, making it easy to accidentally target the wrong environment. Separate directories make this class of mistake structurally impossible.
Seeing prod resources in dev's state list?
This should not happen with directory-based isolation. If you see cross-environment resources, check that you ran terraform init -backend-config=dev.tfbackend in the dev directory and terraform init -backend-config=prod.tfbackend in the prod directory.
help me debug why my Terraform state shows resources from the wrong environment
✔️ Awesome, I've got everything!
Great. Double check you have saved all files in your environments/prod/ directory.
ⓧ I'd like to double check the full code
Here is the complete prod environment configuration for reference.
terraform {
required_version = ">= 1.7.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
backend "azurerm" {}
}
provider "azurerm" {
features {}
subscription_id = var.subscription_id
}
variable "subscription_id" {
description = "Azure Subscription ID"
type = string
}
variable "location" {
description = "Azure region"
type = string
default = "eastus"
}
variable "environment" {
description = "Environment name (dev, prod)"
type = string
}
variable "vnet_address_space" {
description = "Address space for the VNet"
type = list(string)
}
variable "subnets" {
description = "Map of subnets"
type = map(object({
address_prefixes = list(string)
}))
}
variable "vm_size" {
description = "VM size for the compute instance"
type = string
}
variable "admin_password" {
description = "Admin password for the VM"
type = string
sensitive = true
}
resource "azurerm_resource_group" "this" {
name = "rg-${var.environment}-infra"
location = var.location
tags = {
environment = var.environment
managed_by = "terraform"
}
}
module "networking" {
source = "../../modules/networking"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
vnet_name = "vnet-${var.environment}"
vnet_address_space = var.vnet_address_space
subnets = var.subnets
nsg_rules = [
{
name = "allow-ssh"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
},
{
name = "allow-http"
priority = 200
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
},
{
name = "allow-https"
priority = 300
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "443"
source_address_prefix = "*"
destination_address_prefix = "*"
}
]
tags = {
environment = var.environment
managed_by = "terraform"
}
}
module "compute" {
source = "../../modules/compute"
resource_group_name = azurerm_resource_group.this.name
location = azurerm_resource_group.this.location
vm_name = "vm-${var.environment}-web"
vm_size = var.vm_size
subnet_id = module.networking.subnet_ids["web"]
admin_password = var.admin_password
tags = {
environment = var.environment
managed_by = "terraform"
}
}
output "resource_group_name" {
value = azurerm_resource_group.this.name
}
output "vnet_id" {
value = module.networking.vnet_id
}
output "vm_public_ip" {
value = module.compute.public_ip_address
}
output "vm_private_ip" {
value = module.compute.private_ip_address
}
environment = "prod"
location = "eastus"
vnet_address_space = ["10.1.0.0/16"]
subnets = {
web = {
address_prefixes = ["10.1.1.0/24"]
}
app = {
address_prefixes = ["10.1.2.0/24"]
}
data = {
address_prefixes = ["10.1.3.0/24"]
}
}
vm_size = "Standard_B2s"
resource_group_name = "rg-tfstate"
storage_account_name = "your-storage-account-name"
container_name = "tfstate"
key = "prod/terraform.tfstate"
You now have two completely independent environments deployed from the same reusable modules. Next up, the Secret Mission takes this further by adding secrets management with Azure Key Vault.
Secret mission
Add a Key Vault Module
Your infrastructure is deployed, but the VM admin password is sitting in a plain .tfvars file. In production, credentials belong in a secrets manager. Can you create a Key Vault module that stores the password securely and wires it into your compute module?
Clean Up Your Resources
Clean Up Your Resources
This project creates Azure resources that incur costs while running. Decide whether to keep your resources running or delete them entirely.
Cost warning
Your dev environment VM (Standard_B1s) costs approximately $0.012/hr and the prod VM (Standard_B2s) costs approximately $0.049/hr. Combined with public IPs, both environments cost roughly $1-3/day.
If you are done with the project, delete your resources now to avoid unexpected charges.
Resources you used:
- Azure Resource Group for dev (rg-dev-infra) containing a VNet, subnets, NSG, public IP, NIC, and Linux VM.
- Azure Resource Group for prod (rg-prod-infra) containing a VNet, subnets, NSG, public IP, NIC, and Linux VM.
- Azure Resource Group for Terraform state (rg-tfstate) containing a Storage Account and blob container.
- Local project files on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.
- Your resources will continue running and incurring charges (approximately $1-3/day for both environments).
- Monitor your Azure spending in the Azure Portal under Cost Management + Billing.
Pause - I'll come back to this later
There is no pause option for VMs created with this Terraform configuration. Azure VMs provisioned this way must either be kept running or destroyed entirely.
- If you want to stop charges, follow the Delete tab instructions instead.
- Your Terraform code and module files remain on your Desktop. You can re-deploy at any time by running terraform apply again.
Delete - I don't want to use this again
Remove all Azure resources and start fresh. Destroy prod first, then dev, then the state backend.
- Navigate to the prod environment directory and destroy resources:
cd ~/Desktop/terraform-azure-modules/environments/prod
terraform destroy -var-file="prod.tfvars" -var="admin_password=your-password-here" -var="subscription_id=your-subscription-id"
- Type yes when prompted to confirm the destruction.
- Navigate to the dev environment directory and destroy resources:
cd ~/Desktop/terraform-azure-modules/environments/dev
terraform destroy -var-file="dev.tfvars" -var="admin_password=your-password-here" -var="subscription_id=your-subscription-id"
- Type yes when prompted to confirm.
- Delete the state storage account resource group:
az group delete --name rg-tfstate --yes
- Optionally, delete the local project folder from your Desktop.
That's a Wrap!
That's a Wrap!
Nice work! You just built enterprise-grade Terraform infrastructure on Azure. This is the exact modular, multi-environment pattern used by cloud engineering teams managing production deployments at scale.
You've learned how to:
- Built reusable Terraform modules following the flat composition pattern with dependency inversion, wiring networking and compute modules together through explicit inputs and outputs.
- Configured a remote state backend in Azure Blob Storage with native blob lease locking and per-environment state isolation.
- Deployed identical infrastructure to dev and prod environments using directory-based isolation, advanced HCL features like for_each, dynamic blocks, and variable validation.
- Secret Mission: Added a Key Vault module for secrets management, storing VM credentials securely and propagating the change cleanly across environments.
Ready to quiz yourself?