Terraform Modules on Azure: From Building Blocks to a Full-Scale Multi-Tier Application

Terraform Modules on Azure: From Building Blocks to a Full-Scale Multi-Tier Application

Introduction: Turning Terraform Code into Reusable Building Blocks

So far, we have learned Terraform one piece at a time.

We learned that providers let Terraform talk to Azure, resources describe the infrastructure we want to create, variables make our configurations flexible, outputs return useful values, and state keeps track of what Terraform has already deployed.

Those pieces are useful on their own.

But real infrastructure usually grows beyond a few resource blocks in one file.

You might start with one resource group, one virtual network, and one subnet. Then you need the same pattern again for another environment. Then another project needs something similar. Then your dev, test, and prod environments all need the same basic shape, but with different names, regions, address spaces, tags, and sizing.

You could copy and paste the same Terraform code into each environment.

That works for a while.

But it becomes awkward quickly.

If the virtual network pattern changes, you now have to update it in multiple places. If one environment drifts from the others, it becomes harder to know whether that difference was intentional. If every project has its own slightly different version of the same infrastructure, consistency becomes harder to maintain.

Terraform modules give us a cleaner way.

A module lets us package a set of Terraform resources into a reusable building block. We can define something once, such as a resource group, a virtual network, a storage account, or a larger application stack, and then call it with different input values.

The module contains the reusable logic.

The root configuration decides how that logic is used.

In this article, we will build that idea step by step.

We will start with a small module that creates an Azure Resource Group. Then we will build a more useful networking module with a virtual network and subnets. From there, we will look at how root modules and child modules relate, how outputs from one module can become inputs to another, and how modules can be composed into a larger Azure application structure.

By the end, Terraform modules should feel like a practical tool for one clear job:

Describe a reusable piece of infrastructure once, then use it consistently wherever it is needed.

What Terraform Modules Solve

Before modules, Terraform code often starts in one place.

You might have a main.tf file that creates a resource group, a virtual network, a subnet, a storage account, and maybe a virtual machine or an AKS cluster.

For a small deployment, that is fine.

Everything is visible. Everything is close together. You can read the file from top to bottom and understand what Terraform is going to create.

But as the infrastructure grows, the same patterns start to appear again and again.

You need a similar virtual network in dev, test, and prod.

You need the same storage account pattern in another project, but with a different name, region, replication setting, and tags.

You need the same basic application shape repeated across multiple environments, with slightly different values each time.

You could solve that by copying and pasting Terraform code.

Create one version for dev.

Copy it for test.

Copy it again for prod.

Change the names, locations, address spaces, tags, and sizing values.

That works for a while.

But it becomes fragile quickly.

The problem is not that any one resource block is difficult.

The problem is that the pattern now matters.

A virtual network might include subnets, network security groups, route tables, private endpoint subnets, DNS settings, and tags that should stay consistent.

An application environment might include networking, compute, storage, database resources, identities, and outputs that other parts of the deployment depend on.

Those pieces belong together.

If the pattern only exists as copied Terraform code across several folders, you have to keep those copies aligned by hand.

Terraform modules give us a cleaner way.

A module lets you move a reusable infrastructure pattern into one place.

Instead of repeating the same resource blocks every time, you define the pattern once inside a module.

Then you use that module wherever you need it.

The module says:

This is how this piece of infrastructure should be built.

The main Terraform configuration says:

Use that pattern here, with these values.

That is the main value of modules.

They do not replace Terraform resources, variables, outputs, or state.

They organise them.

A module can contain resources.

It can accept variables as inputs.

It can return outputs.

Terraform still tracks everything through state.

The difference is that the infrastructure now has a cleaner boundary around it.

For example, instead of writing the same virtual network resources again and again, you can create a networking module:

networking module
→ creates a virtual network
→ creates subnets
→ applies common tags
→ returns subnet IDs

The reusable logic stays inside the module.

The real values come from outside the module.

That separation is what makes modules useful.

You can keep the infrastructure pattern consistent without forcing every deployment to use the exact same values.

Names can change.

Regions can change.

Tags can change.

Address spaces can change.

But the underlying pattern stays controlled.

That is what Terraform modules solve:

Without modules
→ repeated Terraform code copied across projects and environments

With modules
→ one reusable infrastructure pattern used with different input values

In the next section, we will make that idea concrete by creating a very small module first.

Nothing complicated yet.

Just one Azure Resource Group, wrapped in a module, then used from the main Terraform folder.

What We Are Building

For this article, we will start small.

We are going to build one reusable Terraform module that creates an Azure Resource Group.

That may sound too simple, but it is the right place to begin.

A resource group is easy to understand. It has a name, a location, and tags. It does not need networking, private endpoints, route tables, identity configuration, or complex dependencies.

That makes it a good first module.

The project will have two main parts.

The first part is the main Terraform folder.

This is the folder where we will run commands like:

terraform init
terraform plan
terraform apply

The second part is a module folder.

That module folder will contain the reusable Terraform code for creating a resource group.

The structure will look like this:

terraform-modules-azure/
├── main.tf
├── outputs.tf
└── modules/
    └── resource_group/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

The main folder will not create the resource group directly.

Instead, it will use the code inside:

modules/resource_group/

That is the basic module pattern.

The reusable code lives in one folder.

The main Terraform configuration uses that folder when it needs a resource group.

The flow looks like this:

main Terraform folder
→ uses the resource_group module
→ gives it a name, location, and tags
→ the module creates the Azure Resource Group
→ the module returns useful values

Those useful values might include:

resource group name
resource group location
resource group ID

We will explain each part as we build it.

For now, the important idea is simple:

Instead of writing the resource group code directly in the main Terraform file,
we put that code inside a reusable module folder,
then use that module from the main Terraform folder.

This is enough to show the core module pattern without making the example noisy.

One main Terraform folder.

One module folder.

One Azure Resource Group.

One clear reusable pattern.

Create the First Small Module

Now we can create the module itself.

We will start inside the project folder:

mkdir terraform-modules-azure
cd terraform-modules-azure

Then create a folder for the Resource Group module:

mkdir -p modules/resource_group

The module folder will contain three files:

modules/resource_group/
├── main.tf
├── variables.tf
└── outputs.tf

Each file has a clear job.

main.tf describes what the module creates.

variables.tf describes what the module needs from outside.

outputs.tf describes what the module gives back after the resource is created.

That is the basic shape of many Terraform modules.

module folder
→ receives input values
→ creates infrastructure
→ returns output values

For this first module, the infrastructure is only one Azure Resource Group.

Write the Resource Group Resource

Inside modules/resource_group/, create a file called main.tf.

Add this:

resource "azurerm_resource_group" "rg" {
  name     = var.rg_name
  location = var.location

  tags = var.tags
}

This looks almost the same as a normal Terraform resource block.

The difference is that the values are not written directly into the resource.

We are not doing this:

name     = "dev-infra-rg"
location = "Australia East"

Instead, the module uses variables:

name     = var.rg_name
location = var.location
tags     = var.tags

That is what makes the module reusable.

The module does not decide the exact Resource Group name.

It does not decide the exact Azure region.

It does not decide the exact tags.

It only describes the pattern:

To create a resource group,
I need a name,
I need a location,
and I can apply tags.

The actual values will come from the main Terraform folder later.

Define the Values the Module Needs

Now create variables.tf inside the same module folder.

Add this:

variable "rg_name" {
  type        = string
  description = "Name of the Azure Resource Group."
}

variable "location" {
  type        = string
  description = "Azure region where the Resource Group will be created."
}

variable "tags" {
  type        = map(string)
  description = "Tags to apply to the Resource Group."
  default     = {}
}

These variables are the module’s inputs.

They tell Terraform what values this module expects before it can create the Resource Group.

The module needs:

rg_name
→ the name of the Resource Group

location
→ the Azure region

tags
→ optional metadata to apply to the Resource Group

The tags variable has a default value:

default = {}

That means tags are optional.

If the main Terraform folder does not provide tags, the module can still work. It will just use an empty map.

The other two values are required.

A Resource Group needs a name and a location, so the module expects those values to be provided.

Define the Values the Module Returns

Now create outputs.tf.

Add this:

output "rg_name" {
  value       = azurerm_resource_group.rg.name
  description = "Name of the created Resource Group."
}

output "rg_location" {
  value       = azurerm_resource_group.rg.location
  description = "Azure region of the created Resource Group."
}

output "rg_id" {
  value       = azurerm_resource_group.rg.id
  description = "ID of the created Resource Group."
}

Outputs are the values the module gives back.

After the module creates the Resource Group, other Terraform code may need to know things about it.

For example, another part of the configuration might need the Resource Group name or ID.

So the module returns those values clearly:

rg_name
→ the created Resource Group name

rg_location
→ the Azure region where it was created

rg_id
→ the full Azure Resource ID

This is the other half of the module pattern.

Variables are how values go in.

Outputs are how useful values come back out.

input variables
→ module
→ output values

At this point, the Resource Group module is complete.

The folder now looks like this:

modules/resource_group/
├── main.tf
├── variables.tf
└── outputs.tf

And the module has one clear job:

Create an Azure Resource Group
using a name, location, and tags
provided from outside the module.

We have not used the module yet.

So far, we have only created the reusable building block.

In the next section, we will go back to the main Terraform folder and use this module to create a real Azure Resource Group.

Use the Module from the Main Terraform Folder

We now have reusable Resource Group code sitting inside:

modules/resource_group/

But Terraform will not use that folder automatically.

Terraform does not look through every folder in your project and create whatever it finds. The main Terraform folder has to point to the module folder and say:

Use this module,
with these values.

So now we will go back to the main Terraform folder and use the Resource Group module we just created.

Your project currently looks like this:

terraform-modules-azure/
└── modules/
    └── resource_group/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Now create a main.tf file in the main project folder:

terraform-modules-azure/
├── main.tf
└── modules/
    └── resource_group/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Add this to the main main.tf:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
}

provider "azurerm" {
  features {}
}

module "resource_group" {
  source   = "./modules/resource_group"
  rg_name  = "dev-infra-rg"
  location = "Australia East"

  tags = {
    environment = "dev"
    owner       = "team-infra"
  }
}

This is the part where the module is actually used.

The important block is this one:

module "resource_group" {
  source   = "./modules/resource_group"
  rg_name  = "dev-infra-rg"
  location = "Australia East"

  tags = {
    environment = "dev"
    owner       = "team-infra"
  }
}

This tells Terraform:

Use the module in ./modules/resource_group
and create a resource group using these values.

The source line tells Terraform where the module code lives:

source = "./modules/resource_group"

In this case, the module is local. It lives in a folder inside the same project.

The other values are the inputs we are giving to the module:

rg_name  = "dev-infra-rg"
location = "Australia East"
tags = {
  environment = "dev"
  owner       = "team-infra"
}

These match the variables we defined inside the module:

rg_name
location
tags

So the relationship looks like this:

main Terraform folder
→ gives values to the module

resource_group module
→ uses those values to create the Azure Resource Group

For now, we are writing the values directly inside the module block.

That is fine for this first example.

Later, you would usually move values like names, locations, and tags into variables or .tfvars files so different environments can reuse the same module more cleanly.

But for now, keeping the values here makes the flow easier to see.

Return a Value from the Module

The module creates the Resource Group, but we also want to see something useful come back from it.

Create an outputs.tf file in the main Terraform folder:

terraform-modules-azure/
├── main.tf
├── outputs.tf
└── modules/
    └── resource_group/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

Add this:

output "resource_group_id" {
  value = module.resource_group.rg_id
}

This output reads the rg_id value from the module.

Inside the module, we already created this output:

output "rg_id" {
  value       = azurerm_resource_group.rg.id
  description = "ID of the created Resource Group."
}

Now the main Terraform folder can access that value using:

module.resource_group.rg_id

Read that from left to right:

module
→ from a module block

resource_group
→ the module block named "resource_group"

rg_id
→ the output returned by that module

So the module creates the Resource Group, returns its ID, and the main Terraform folder exposes that ID as an output.

The flow now looks like this:

main Terraform folder
→ uses resource_group module
→ passes in name, location, and tags
→ module creates the Azure Resource Group
→ module returns the Resource Group ID
→ main Terraform folder displays that ID as an output

Run Terraform

Now run Terraform from the main project folder:

terraform init

This prepares the working folder.

It installs the Azure provider and prepares Terraform to use the local module.

Next, run:

terraform plan

Terraform reads the main main.tf, finds the module "resource_group" block, follows the source path into modules/resource_group, and works out what needs to be created.

You should see that Terraform plans to create one Azure Resource Group.

Then apply the configuration:

terraform apply

Review the plan, then approve it when you are ready.

Terraform will create the Resource Group in Azure using the values from the main Terraform folder:

name     = dev-infra-rg
location = Australia East
tags     = environment/dev, owner/team-infra

When the apply finishes, Terraform should show an output like this:

Outputs:

resource_group_id = "/subscriptions/xxxx/resourceGroups/dev-infra-rg"

That output came from the module.

The module created the Resource Group.

The module returned the Resource Group ID.

The main Terraform folder displayed that value.

That is the module pattern working end to end:

module folder
→ contains reusable Terraform code

main Terraform folder
→ uses the module with real values

Terraform
→ creates the Azure resource

module output
→ returns useful information back to the main folder

At this point, we have used our first Terraform module.

In the next section, we can give these two folders their official Terraform names: root module and child module.

Understand Root and Child Modules

Now that we have used the module once, we can give the two folders their official Terraform names.

So far, we have been using plain language:

main Terraform folder
module folder

Terraform has names for these.

The main Terraform folder is called the root module.

The reusable folder we used from modules/resource_group is called a child module.

That might feel a little strange at first.

You might think:

Wait — the main folder is a module too?

Yes.

In Terraform, a module is just a folder that contains Terraform configuration files.

The folder where you run Terraform from is the root module.

That is where Terraform starts.

In our project, this folder is the root module:

terraform-modules-azure/          ← root module
├── main.tf
├── outputs.tf
└── modules/
    └── resource_group/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

The root module is the entry point.

It is where we ran:

terraform init
terraform plan
terraform apply

It is also where we decided to use the Resource Group module:

module "resource_group" {
  source   = "./modules/resource_group"
  rg_name  = "dev-infra-rg"
  location = "Australia East"

  tags = {
    environment = "dev"
    owner       = "team-infra"
  }
}

So the root module does not have to contain every resource directly.

Instead, it can decide which reusable modules to use.

In this example, the root module uses one child module:

root module
→ uses resource_group child module

The child module is the reusable Resource Group code:

modules/resource_group/           ← child module
├── main.tf
├── variables.tf
└── outputs.tf

It contains the resource group pattern.

It says:

To create a resource group,
I need a name,
I need a location,
and I can apply tags.

But it does not decide the real values.

The root module gives it those values.

That is the relationship:

root module
→ decides what to use
→ provides real values

child module
→ contains reusable infrastructure logic
→ creates resources
→ returns useful outputs

In our small example, the root module only calls one child module.

But this is where the pattern becomes powerful.

A larger root module could call several child modules:

root module
├── resource_group module
├── networking module
├── virtual_machine module
├── database module
└── storage module

Each child module can focus on one part of the infrastructure.

The Resource Group module can contain the Resource Group pattern.

The networking module can contain the virtual network, subnets, and network rules.

The virtual machine module can contain the VM, network interface, disk settings, and common tags.

The root module then becomes the place where those building blocks are assembled.

It might say:

Create the resource group first.
Create the network inside that resource group.
Create the virtual machine in one of the subnets.
Return the important IDs and names.

That is the larger module model.

The reusable logic lives inside child modules.

The root module controls how those modules are used together.

For now, our root module is still simple.

It gives the Resource Group module hard-coded values directly in main.tf.

That is fine for learning.

Later, we can make the root module cleaner by moving those values into variables and .tfvars files.

But the core pattern stays the same:

root module
→ the starting point

child module
→ a reusable building block

module inputs
→ values passed from the root module into the child module

module outputs
→ values returned from the child module back to the root module

That is the official name for what we have already built.

We did not just create a folder and another folder.

We created a root module that uses a child module.

Next, we can make the child module more useful by building something closer to real Azure infrastructure: a networking module with a virtual network and subnets.

Build a More Useful Networking Module

The Resource Group module was intentionally small.

It helped us understand the basic pattern:

module folder
→ receives input values
→ creates infrastructure
→ returns output values

That is useful, but a Resource Group module is still a very simple example.

Most real Terraform modules are more interesting than that.

They usually group related resources that belong together.

A good example is networking.

A virtual network is rarely useful on its own. It usually needs at least one subnet. In larger environments, it may also need network security groups, route tables, private endpoint subnets, DNS settings, and more.

We will not add all of that yet.

For now, we will keep the networking module simple:

networking module
→ creates a virtual network
→ creates one subnet inside it
→ returns the virtual network ID
→ returns the subnet ID

This is enough to show why modules become useful.

The Resource Group module showed how a module works.

The networking module shows how a module can hold a small infrastructure pattern.

Create the Networking Module Folder

From the main project folder, create a new module folder:

mkdir -p modules/networking

The project now has two module folders:

terraform-modules-azure/
├── main.tf
├── outputs.tf
└── modules/
    ├── resource_group/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf

    └── networking/
        ├── main.tf
        ├── variables.tf
        └── outputs.tf

The resource_group module creates the Azure Resource Group.

The networking module will create the virtual network and subnet.

Write the Networking Resources

Inside modules/networking/, create a file called main.tf.

Add this:

resource "azurerm_virtual_network" "vnet" {
  name                = var.vnet_name
  address_space       = var.address_space
  location            = var.location
  resource_group_name = var.resource_group_name

  tags = var.tags
}

resource "azurerm_subnet" "subnet" {
  name                 = var.subnet_name
  resource_group_name  = var.resource_group_name
  virtual_network_name = azurerm_virtual_network.vnet.name
  address_prefixes     = var.subnet_address_prefix
}

This module creates two Azure resources:

azurerm_virtual_network
azurerm_subnet

The subnet belongs to the virtual network.

We can see that here:

virtual_network_name = azurerm_virtual_network.vnet.name

That line tells Terraform:

Create the subnet inside the virtual network created by this module.

So the module is not just holding two unrelated resources.

It is describing a small networking pattern:

virtual network
→ contains subnet

Terraform understands that relationship.

Because the subnet refers to the virtual network, Terraform knows the virtual network must be created before the subnet.

That is one of the reasons modules are useful.

They let us keep related resources and their relationships together in one place.

Define the Values the Networking Module Needs

Now create variables.tf inside modules/networking/.

Add this:

variable "vnet_name" {
  type        = string
  description = "Name of the virtual network."
}

variable "address_space" {
  type        = list(string)
  description = "Address space for the virtual network."
}

variable "subnet_name" {
  type        = string
  description = "Name of the subnet."
}

variable "subnet_address_prefix" {
  type        = list(string)
  description = "Address prefix for the subnet."
}

variable "location" {
  type        = string
  description = "Azure region where the networking resources will be created."
}

variable "resource_group_name" {
  type        = string
  description = "Name of the Resource Group where the networking resources will be created."
}

variable "tags" {
  type        = map(string)
  description = "Tags to apply to the networking resources."
  default     = {}
}

These variables tell us what the networking module needs from outside.

It needs values for the network itself:

vnet_name
address_space

It needs values for the subnet:

subnet_name
subnet_address_prefix

And it needs to know where to create the resources:

location
resource_group_name

That last value is important.

The networking module does not create the Resource Group.

It only creates networking resources inside a Resource Group.

So it needs someone else to tell it which Resource Group to use.

For now, just notice that shape:

resource_group module
→ creates the Resource Group

networking module
→ needs the Resource Group name
→ creates the virtual network and subnet inside it

This is the first hint of modules working together.

One module can create something.

Another module can use a value from it.

We will wire that up from the main Terraform folder in the next section.

Define the Values the Networking Module Returns

Now create outputs.tf inside modules/networking/.

Add this:

output "vnet_id" {
  value       = azurerm_virtual_network.vnet.id
  description = "ID of the created virtual network."
}

output "vnet_name" {
  value       = azurerm_virtual_network.vnet.name
  description = "Name of the created virtual network."
}

output "subnet_id" {
  value       = azurerm_subnet.subnet.id
  description = "ID of the created subnet."
}

output "subnet_name" {
  value       = azurerm_subnet.subnet.name
  description = "Name of the created subnet."
}

These outputs return useful values from the networking module.

After the module creates the virtual network and subnet, other Terraform code may need those values.

For example, a virtual machine module might need the subnet ID.

An AKS module might need the subnet ID.

A private endpoint might need the subnet ID.

So the networking module returns it clearly:

subnet_id
→ the ID of the subnet created by this module

That is the same input/output pattern we used earlier.

input variables
→ networking module
→ output values

The difference is that this module now manages more than one resource.

It contains a small infrastructure design:

virtual network
→ subnet

At this point, the networking module is complete.

But just like before, creating the module folder is not enough.

Terraform will not use it automatically.

In the next section, we will go back to the main Terraform folder and connect the modules together.

The Resource Group module will create the Resource Group.

The networking module will use that Resource Group name.

That is where modules start to talk to each other.

Pass Values Between Modules

We now have two modules:

resource_group module
→ creates the Azure Resource Group
→ returns the Resource Group name, location, and ID

networking module
→ creates a virtual network and subnet
→ needs a Resource Group name and location
→ returns the virtual network ID and subnet ID

The important question now is:

How does the networking module know which Resource Group to use?

We could hard-code the Resource Group name again inside the networking module call.

But that would miss the point.

The Resource Group module already creates the Resource Group and returns its name.

So the main Terraform folder can take the output from the Resource Group module and pass it into the networking module.

The flow looks like this:

resource_group module
→ creates Resource Group
→ returns Resource Group name and location

main Terraform folder
→ reads those outputs
→ passes them into networking module

networking module
→ creates virtual network and subnet inside that Resource Group

That is how modules work together.

Not by reaching into each other directly.

The main Terraform folder wires them together.

Use the Networking Module

Go back to the main main.tf file.

It already has the Resource Group module:

module "resource_group" {
  source   = "./modules/resource_group"
  rg_name  = "dev-infra-rg"
  location = "Australia East"

  tags = {
    environment = "dev"
    owner       = "team-infra"
  }
}

Now add the networking module below it:

module "networking" {
  source = "./modules/networking"

  vnet_name             = "dev-vnet"
  address_space         = ["10.0.0.0/16"]
  subnet_name           = "dev-subnet"
  subnet_address_prefix = ["10.0.1.0/24"]

  location            = module.resource_group.rg_location
  resource_group_name = module.resource_group.rg_name

  tags = {
    environment = "dev"
    owner       = "team-infra"
  }
}

This is the important part:

location            = module.resource_group.rg_location
resource_group_name = module.resource_group.rg_name

The networking module needs a location and a Resource Group name.

Instead of writing those values manually again, we are getting them from the Resource Group module.

Read this from left to right:

module.resource_group.rg_name

It means:

module
→ read from a module block

resource_group
→ the module block named "resource_group"

rg_name
→ the output returned by that module

So this line:

resource_group_name = module.resource_group.rg_name

means:

Use the Resource Group name returned by the resource_group module
as the Resource Group name for the networking module.

That is the module handoff.

One module creates something.

The main Terraform folder takes its output.

Another module receives that value as an input.

resource_group output
→ main Terraform folder
→ networking input

Return the Networking Outputs

Now update the main outputs.tf file so we can see what the networking module created.

You may already have this output from the previous section:

output "resource_group_id" {
  value = module.resource_group.rg_id
}

Add these outputs below it:

output "vnet_id" {
  value = module.networking.vnet_id
}

output "subnet_id" {
  value = module.networking.subnet_id
}

These outputs read values from the networking module:

module.networking.vnet_id
module.networking.subnet_id

That means the main Terraform folder can now display values from both modules:

resource_group module
→ Resource Group ID

networking module
→ virtual network ID
→ subnet ID

The project is still small, but the pattern is now much more powerful.

The main Terraform folder is no longer just using one reusable building block.

It is connecting two of them.

Run Terraform Again

Now run:

terraform plan

Terraform will read the main Terraform folder, follow both module source paths, and work out the full deployment.

It will see that:

resource_group module
→ creates the Resource Group

networking module
→ creates the virtual network and subnet
→ depends on values from the Resource Group module

Because the networking module uses:

module.resource_group.rg_name
module.resource_group.rg_location

Terraform understands that the Resource Group must exist before the networking resources can be created.

You do not need to manually tell Terraform:

Create the Resource Group first.
Then create the virtual network.
Then create the subnet.

The references between values create that relationship.

Now apply the configuration:

terraform apply

After Terraform finishes, you should see outputs like:

Outputs:

resource_group_id = "/subscriptions/xxxx/resourceGroups/dev-infra-rg"
vnet_id           = "/subscriptions/xxxx/resourceGroups/dev-infra-rg/providers/Microsoft.Network/virtualNetworks/dev-vnet"
subnet_id         = "/subscriptions/xxxx/resourceGroups/dev-infra-rg/providers/Microsoft.Network/virtualNetworks/dev-vnet/subnets/dev-subnet"

The exact subscription ID will be different, but the pattern is the important part.

The Resource Group module created the Resource Group.

The networking module used the Resource Group name and location.

The networking module created the virtual network and subnet.

The main Terraform folder displayed the outputs.

The full flow now looks like this:

main Terraform folder
├── calls resource_group module
│   ├── passes in name, location, and tags
│   └── receives Resource Group outputs

└── calls networking module
    ├── passes in network settings
    ├── uses Resource Group outputs as inputs
    └── receives virtual network and subnet outputs

That is how multiple Terraform modules work together.

The modules stay focused.

The Resource Group module knows how to create a Resource Group.

The networking module knows how to create a virtual network and subnet.

The main Terraform folder decides how those modules are connected.

That is the key idea:

Modules do not need to know everything about the whole deployment.

Each module owns one pattern.

The main Terraform folder assembles those patterns into a working infrastructure design.

At this point, we have moved from using one module to connecting multiple modules.

That is the point where Terraform modules start to feel less like a folder structure trick and more like an infrastructure design pattern.

The Terraform Module Mental Model

At this point, we have seen the module pattern from a few angles.

We created a small Resource Group module.

We used it from the main Terraform folder.

We gave the main Terraform folder and module folder their official names: root module and child module.

Then we added a networking module and passed values from one module into another.

So let’s slow down and put the full mental model together.

A Terraform resource block creates one thing.

For example:

resource block
→ creates a Resource Group

A Terraform module groups Terraform code into a reusable unit.

For example:

resource_group module
→ creates a Resource Group
→ accepts a name, location, and tags
→ returns the Resource Group name, location, and ID

A module can be very small, like the Resource Group module.

Or it can describe a small infrastructure pattern, like the networking module:

networking module
→ creates a virtual network
→ creates a subnet inside it
→ returns the virtual network ID
→ returns the subnet ID

The important idea is not the number of resources.

The important idea is the boundary.

A module gives a piece of infrastructure a clear boundary.

Inside the module is the reusable logic.

Outside the module are the values that change.

That is why variables and outputs matter so much.

Variables are how values go into a module:

main Terraform folder
→ passes values into module

Outputs are how useful values come back out:

module
→ returns values to main Terraform folder

So the pattern looks like this:

input variables
→ module
→ output values

The root module is where Terraform starts.

It is the main Terraform folder where we run:

terraform init
terraform plan
terraform apply

The root module decides which child modules to use.

It passes values into those modules.

It reads values back from those modules.

And it connects modules together when one module needs something created by another.

In our example, the relationship looked like this:

root module
├── calls resource_group module
│   └── receives Resource Group name and location

└── calls networking module
    └── passes Resource Group name and location into it

The networking module did not need to know everything about the Resource Group module.

It only needed the values required to do its own job.

That is the design habit modules encourage.

Each module should own one clear pattern.

The Resource Group module owns the Resource Group pattern.

The networking module owns the virtual network and subnet pattern.

The root module owns the assembly.

It decides how those patterns are used together.

That gives us a clean way to think about Terraform modules:

resource
→ creates an individual piece of infrastructure

child module
→ packages a reusable infrastructure pattern

root module
→ assembles one or more patterns into a deployment

variables
→ values going in

outputs
→ values coming back out

Modules are not magic.

They do not replace Terraform resources.

They do not replace variables.

They do not replace outputs.

They do not replace state.

They organise those Terraform concepts into clearer boundaries.

That is the real mental shift.

Instead of thinking:

I have a large Terraform file with many resource blocks.

You can start thinking:

I have reusable infrastructure patterns,
and my root module decides how to assemble them.

That is where Terraform modules become useful.

Not because they make one Resource Group easier to create.

But because they give your infrastructure code a structure that can grow without turning into repeated copy-and-paste.

Where This Leads

At this point, we have reached the main goal of the article.

We started with a simple problem:

Terraform code can become repetitive as infrastructure grows.

Then we introduced modules as the cleaner pattern:

Put reusable infrastructure logic in one folder.
Use that folder from the main Terraform configuration.
Pass values in.
Return useful values back out.

We started with one small module that created an Azure Resource Group.

Then we used that module from the main Terraform folder.

Then we gave the two folders their official names:

root module
→ where Terraform starts

child module
→ reusable Terraform code used by another module

Then we added a networking module and connected the two modules together.

The Resource Group module created the Resource Group.

The networking module needed the Resource Group name and location.

The root module passed those values from one module into the other.

That is the core Terraform module pattern.

root module
→ calls child modules
→ passes input values
→ reads output values
→ connects reusable infrastructure patterns together

This pattern can grow from here.

A networking module could eventually create several subnets instead of one.

A compute module could create a virtual machine or an AKS cluster.

A storage module could create a storage account and containers.

A database module could create a database and return its connection details.

But the basic idea does not change.

variables go in
resources are created
outputs come back out

That is why modules matter.

They are not just a way to make Terraform folders look tidy.

They are a way to give your infrastructure code structure.

Instead of one large Terraform file trying to describe everything, you can start thinking in reusable patterns:

resource group pattern
networking pattern
compute pattern
storage pattern
database pattern

The root module becomes the place where those patterns are assembled into a real deployment.

But once Terraform code becomes this structured, another question appears.

So far, Terraform has probably been keeping state locally in your project folder.

That is fine while you are learning.

But in a real project, state should not live only on one person’s machine.

The code can live in Git.

The infrastructure can live in Azure.

But Terraform also needs a safe place to store its state.

That is where remote state and backends come in.

In the next article, we will move from local Terraform state to remote state on Azure.

We will look at how Terraform can store its state in an Azure Storage Account, why that matters, and how remote state helps make Terraform safer when projects become more serious.