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
The architecture remains reusable while the environment can still be sized appropriately.
Using Outputs
Terraform modules can also expose useful information.
Inside outputs.tf:
output "resource_group_name" {
value = azurerm_resource_group.app.name
}
output "virtual_network_name" {
value = azurerm_virtual_network.app.name
}
output "subnet_id" {
value = azurerm_subnet.app.id
}
Another module can then consume these values.
Network Module
↓
Kubernetes Module
↓
Application Module
Each part of the infrastructure can have a clear responsibility.
Remote Terraform State
One important consideration when multiple engineers work with Terraform is state management.
By default, Terraform stores state locally:
terraform.tfstate
This works for individual testing, but it becomes risky for team environments.
If two engineers maintain separate copies of the state file, Terraform may not have an accurate view of the infrastructure.
For shared environments, I prefer storing Terraform state remotely.
For example, Azure Storage can be used as a backend:
terraform {
backend "azurerm" {
resource_group_name = "rg-terraform-state"
storage_account_name = "tfstatecompany"
container_name = "tfstate"
key = "cloudapp-prod.tfstate"
}
}
Engineer
↓
Terraform
↓
Remote State + Azure Infrastructure
Using remote state gives the team a central state location.
In a production setup, I would also apply appropriate access controls and state locking capabilities where supported.
Keeping State Separate Between Environments
Development and production should not normally share the same Terraform state.
cloudapp-dev.tfstate
cloudapp-test.tfstate
cloudapp-prod.tfstate
This separation reduces the risk of a development change accidentally affecting production infrastructure.
The environments may share Terraform modules, but their state should remain isolated.
Never Store Secrets in Terraform Code
Terraform variables make configuration reusable, but sensitive information should still be handled carefully.
I would avoid writing something like:
database_password = "MyPassword123"
inside a Terraform file committed to Git.
Secrets can instead come from:
Secure CI/CD variables
Secret management platforms
Environment variables
Managed identity
Cloud-native secret stores
For example:
export TF_VAR_database_password="secure-value"
Terraform can consume the value through:
variable "database_password" {
type = string
sensitive = true
}
Important: sensitive values may still exist inside Terraform state, so access to state storage must also be protected.
Adding Validation
As Terraform projects become more reusable, validation becomes increasingly useful.
variable "environment" {
type = string
validation {
condition = contains(
["dev", "test", "prod"],
var.environment
)
error_message = "Environment must be dev, test or prod."
}
}
This prevents someone from accidentally providing an unsupported environment value.
Instead of discovering the mistake after infrastructure creation, Terraform stops during validation.
Formatting and Validation in CI/CD
Terraform should also be validated automatically.
A pipeline can run:
terraform fmt -check
terraform init
terraform validate
terraform plan
Developer
↓
Git Repository
↓
CI Pipeline
↓
Terraform Format + Terraform Validate
↓
Terraform Plan
↓
Review
↓
Terraform Apply
This connects naturally with the CI/CD practices I discussed previously.
Application delivery and infrastructure delivery can both follow controlled automated processes.
Terraform Plan Before Apply
One of the Terraform commands I use frequently is:
terraform plan
It allows us to review what Terraform intends to change before modifying the environment.
A plan may show:
+ create
~ update
- destroy
This is particularly important for production.
I would avoid automatically applying every infrastructure change without first understanding the proposed changes.
Terraform Validate
↓
Terraform Plan
↓
Plan Review
↓
Approval
↓
Terraform Apply
Avoiding Infrastructure Drift
One of the main benefits of Infrastructure as Code is that infrastructure configuration becomes visible and repeatable.
If an engineer manually changes a resource in the cloud portal, the actual environment may start differing from the Terraform definition.
This is known as configuration drift.
Terraform Configuration
↓
Expected Infrastructure
↓
Manual Cloud Change
↓
Configuration Drift
Running Terraform plan again helps identify differences between the declared infrastructure and the actual environment.
This is another reason I prefer infrastructure changes to go through code wherever practical.
Module Design Should Stay Simple
It is possible to make Terraform modules extremely configurable.
However, too much flexibility can make a module difficult to understand.
I prefer modules that have a clear responsibility.
modules/
│
├── networking/
├── kubernetes/
├── database/
├── monitoring/
└── application/
Instead of creating one huge module that controls every possible infrastructure component, smaller modules are usually easier to test, review and reuse.
A More Mature Terraform Workflow
Developer
↓
Git Repository
↓
CI Pipeline
↓
Format + Validate + Security Checks
↓
Terraform Plan
↓
Review
↓
Approval
↓
Terraform Apply
↓
Dev Test Prod
The same infrastructure patterns are reused, but deployment remains controlled for each environment.
What I Learned From Structuring Terraform This Way
The main lesson for me was that Infrastructure as Code is not only about replacing portal clicks with Terraform commands.
The real value comes from creating infrastructure that is:
Repeatable
Reviewable
Version controlled
Consistent
Easier to recover
Easier to scale across environments
Reusable modules help remove unnecessary duplication.
Separate state helps protect environments.
Terraform plan provides visibility before changes are made.
CI/CD validation adds another layer of consistency.
Together, these practices make infrastructure easier to manage as systems grow.
Conclusion
In this article, I explored how Terraform can be structured for multiple environments without maintaining separate copies of the same infrastructure code.
Reusable Terraform Modules
↓
Environment Configuration
↓
Dev Test Prod
We covered:
Reusable Terraform modules
Environment-specific variables
Terraform outputs
Remote state
Separate state between environments
Secret handling
Variable validation
Terraform formatting and validation
Terraform plan and approval
Infrastructure drift
Modular architecture
For me, this was an important step beyond basic infrastructure automation.
Once infrastructure becomes reusable and version controlled, it becomes much easier to connect it with CI/CD, cloud-native applications and automated deployment workflows.
My next step: In the next stage of my journey, I will move deeper into Kubernetes and look at how applications can be designed and deployed more reliably in production environments.

Join the conversation! Your thoughts help the community grow.