After working with CI/CD pipelines, one of the next challenges I started focusing on was infrastructure consistency.
Application deployment can be automated, but if development, testing and production environments are created differently, teams can still face configuration drift, deployment failures and unnecessary manual work.
This is where Infrastructure as Code becomes useful.
In this article, I will show how I structure Terraform so that the same infrastructure code can be reused across multiple environments without copying complete configurations for development, testing and production.
Goal
Move from separately managed infrastructure:
Development Infrastructure → Manual Setup
Testing Infrastructure → Manual Setup
Production Infrastructure → Manual Setup
to a reusable model:
Terraform Modules
↓
Development Testing Production
Why Reusable Terraform Matters
A small Terraform project may begin with only a few resources.
For example:
resource "azurerm_resource_group" "main" {
name = "dev-resource-group"
location = "UK South"
}
This is fine when we are experimenting.
The problem starts when we need another environment.
We might copy the configuration and change the name:
resource "azurerm_resource_group" "main" {
name = "test-resource-group"
location = "UK South"
}
Then another configuration might be created for production.
terraform-dev/
terraform-test/
terraform-prod/
Over time, this creates unnecessary duplication.
A change made in one environment may not be applied to another. Security settings may become inconsistent and resource naming can become difficult to manage.
Instead of duplicating infrastructure code, I prefer separating reusable infrastructure logic from environment-specific values.
Terraform Project Structure
A simple project structure can look like this:
terraform/
│
├── modules/
│ └── app-infrastructure/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
│
├── environments/
│ ├── dev/
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ │
│ ├── test/
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ │
│ └── prod/
│ ├── main.tf
│ └── terraform.tfvars
│
└── providers.tf
The important idea is simple: the module contains the infrastructure design, while the environment folders contain the values that change between environments.
Creating a Reusable Module
For this example, imagine we want to create infrastructure for an application hosted in Microsoft Azure.
Our module will create:
Resource Group
Virtual Network
Subnet
Storage Account
The module can be stored inside:
modules/app-infrastructure/
variables.tf
variable "environment" {
description = "Environment name"
type = string
}
variable "location" {
description = "Azure region"
type = string
}
variable "address_space" {
description = "Virtual network address space"
type = list(string)
}
variable "subnet_prefix" {
description = "Application subnet address prefix"
type = list(string)
}
Instead of hardcoding values directly into resources, we define variables.
This allows the same Terraform module to receive different configuration depending on the target environment.
Creating the Infrastructure
Inside main.tf, we can create the resources.
resource "azurerm_resource_group" "app" {
name = "rg-cloudapp-${var.environment}"
location = var.location
}
resource "azurerm_virtual_network" "app" {
name = "vnet-cloudapp-${var.environment}"
location = azurerm_resource_group.app.location
resource_group_name = azurerm_resource_group.app.name
address_space = var.address_space
}
resource "azurerm_subnet" "app" {
name = "snet-cloudapp-${var.environment}"
resource_group_name = azurerm_resource_group.app.name
virtual_network_name = azurerm_virtual_network.app.name
address_prefixes = var.subnet_prefix
}
resource "azurerm_storage_account" "app" {
name = "cloudapp${var.environment}store"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
account_tier = "Standard"
account_replication_type = "LRS"
}
Notice how the environment is included in the resource names.
If:
environment = "dev"
Terraform creates names such as:
rg-cloudapp-dev
vnet-cloudapp-dev
snet-cloudapp-dev
For production:
environment = "prod"
the same module creates:
rg-cloudapp-prod
vnet-cloudapp-prod
snet-cloudapp-prod
We are changing configuration values, not copying infrastructure logic.
Calling the Module
Now we can call the reusable module from the development environment.
Inside:
environments/dev/main.tf
we can write:
module "app_infrastructure" {
source = "../../modules/app-infrastructure"
environment = "dev"
location = "UK South"
address_space = ["10.10.0.0/16"]
subnet_prefix = ["10.10.1.0/24"]
}
For production:
module "app_infrastructure" {
source = "../../modules/app-infrastructure"
environment = "prod"
location = "UK South"
address_space = ["10.30.0.0/16"]
subnet_prefix = ["10.30.1.0/24"]
}
The underlying architecture remains consistent. Only the environment-specific configuration changes.
Using terraform.tfvars
Another way to keep environment values separate is using .tfvars files.
Development
environment = "dev"
location = "UK South"
address_space = [
"10.10.0.0/16"
]
subnet_prefix = [
"10.10.1.0/24"
]
Production
environment = "prod"
location = "UK South"
address_space = [
"10.30.0.0/16"
]
subnet_prefix = [
"10.30.1.0/24"
]
Terraform can then be executed with:
terraform plan -var-file="terraform.tfvars"
and:
terraform apply -var-file="terraform.tfvars"
Development and Production Should Not Always Be Identical
Reusable infrastructure does not mean every environment must have exactly the same capacity.
Development may only need a small environment, while production may require additional resources, stronger redundancy or higher capacity.
For example:
variable "replica_count" {
description = "Number of application instances"
type = number
}
Development could use:
replica_count = 1
while production could use:
replica_count = 3

Join the conversation! Your thoughts help the community grow.