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:

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:

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:

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:

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.