Back to Terraform & IaC
Q12Question 12 of 13

Terraform Modules and Workspaces, and Structuring Reusable Modules for Multiple Environments

Key Concepts
  • A module is a reusable, parameterized container of resources defined once and instantiated many times with different inputs.
  • Good modules have typed input variables, clear outputs, a single responsibility, and are versioned when published.
  • A workspace is a named state copy of one configuration, useful for ephemeral sandboxes but risky for the production boundary.
  • The recommended multi-environment pattern is per-environment roots that compose shared modules with separate state.
  • Each environment root has its own backend state key, its own variable values, and calls the same shared modules.
  • Never use workspaces to separate production from lower environments because a wrong workspace selection can hit production.

Definition

Modules are reusable parameterized infrastructure blueprints, workspaces are named state copies, and the safest multi-environment layout composes shared modules from thin per-environment roots with separate state.

Terraform modules and workspaces serve different purposes for code reuse and environment separation.

  • Module – a reusable, parameterized container of resources with typed input variables, resources, and output values.
  • Workspace – a named instance of a single configuration's state, letting the same code hold multiple independent state copies.
  • Multi-environment pattern – reusable modules composed from thin per-environment root configurations, each with its own backend state and variable values.
  • Workspace caution – all workspaces share the same backend and code, so a wrong selection can hit production.

At the National Bank of India, a module is the standard blueprint for a branch: counters, vault, and ATMs designed once, then stamped out with different inputs for a small town branch and a large city branch. A workspace is keeping separate inventory ledgers for the same blueprint. The strongest setup gives each real environment its own blueprint instantiation and its own locked ledger, so a renovation in the test branch can never touch the main branch.

Understanding the Concepts

What is a Terraform module?

A module is simply a directory of Terraform files with input variables, resources, and output values. The root configuration is itself a module. Child modules are called with a module block that passes inputs.

HCL
# modules/network/variables.tf
variable "vpc_cidr" {
  type        = string
  description = "CIDR block for the banking VPC"
}

variable "environment" {
  type        = string
  description = "Environment name: dev, staging, or prod"
}

# modules/network/main.tf
resource "aws_vpc" "main" {
  cidr_block = var.vpc_cidr
  tags = {
    Name        = "banking-vpc-${var.environment}"
    Environment = var.environment
  }
}

resource "aws_subnet" "private" {
  count      = 3
  vpc_id     = aws_vpc.main.id
  cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
  tags = {
    Name = "private-${var.environment}-${count.index}"
  }
}

# modules/network/outputs.tf
output "vpc_id" {
  value = aws_vpc.main.id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

How do you call a module?

The root configuration calls child modules and wires one module's outputs into another's inputs.

HCL
# environments/prod/main.tf
module "network" {
  source      = "../../modules/network"
  vpc_cidr    = "10.0.0.0/16"
  environment = "prod"
}

module "eks" {
  source             = "../../modules/eks"
  vpc_id             = module.network.vpc_id
  subnet_ids         = module.network.private_subnet_ids
  cluster_name       = "banking-cluster-prod"
  node_instance_type = "m5.2xlarge"
  min_nodes          = 5
  max_nodes          = 20
}

What makes a good module?

Good modules follow the same principles as well-encapsulated functions: clear parameters, a single responsibility, and a clear return value.

HCL
# Good module design principles:
# 1. Typed variables with descriptions and defaults
variable "node_instance_type" {
  type        = string
  default     = "m5.xlarge"
  description = "EC2 instance type for EKS worker nodes"
}

# 2. Single responsibility: this module does ONLY networking
# Not networking + compute + database

# 3. Outputs expose exactly what callers need
output "vpc_id" {
  value       = aws_vpc.main.id
  description = "The VPC ID for other modules to reference"
}

# 4. Versioned when published to a registry
module "network" {
  source  = "nbi-registry/banking-network/aws"
  version = "2.1.0"   # pinned version for reproducibility
}

What is a Terraform workspace?

A workspace is a named state within a single backend. It lets the same configuration manage multiple independent state copies.

BASH
# Create and switch workspaces
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

terraform workspace select dev
terraform apply -var-file=dev.tfvars

terraform workspace select prod
terraform apply -var-file=prod.tfvars

terraform workspace list
#   default
#   dev
# * staging
#   prod
HCL
# Branch behaviour on workspace name
locals {
  instance_type = terraform.workspace == "prod" ? "m5.2xlarge" : "t3.medium"
  node_count    = terraform.workspace == "prod" ? 10 : 2
}

Why are workspaces risky for production?

All workspaces share the same backend and configuration code. A change to the code affects every workspace, and it is easy to run a command against the wrong workspace.

BASH
# DANGER: you think you are in dev, but you are in prod
terraform workspace show
# prod   <- oops

terraform destroy -auto-approve
# Just destroyed production infrastructure!

How do you structure multiple environments safely?

The recommended pattern is per-environment root configurations that compose shared modules.

TEXT
terraform/
  modules/
    network/        # reusable VPC, subnets, routing
    eks/            # reusable EKS cluster
    database/       # reusable RDS instance
  environments/
    dev/
      main.tf       # calls modules with dev inputs
      backend.tf    # separate state key: dev/terraform.tfstate
      dev.tfvars    # dev variable values
    staging/
      main.tf
      backend.tf    # staging/terraform.tfstate
      staging.tfvars
    prod/
      main.tf
      backend.tf    # prod/terraform.tfstate
      prod.tfvars
HCL
# environments/dev/backend.tf
terraform {
  backend "s3" {
    bucket         = "nbi-terraform-state"
    key            = "dev/banking/terraform.tfstate"   # separate state
    region         = "ap-south-1"
    dynamodb_table = "nbi-terraform-locks"
  }
}

# environments/dev/main.tf
module "network" {
  source      = "../../modules/network"
  vpc_cidr    = "10.1.0.0/16"    # dev CIDR
  environment = "dev"
}

module "eks" {
  source             = "../../modules/eks"
  vpc_id             = module.network.vpc_id
  subnet_ids         = module.network.private_subnet_ids
  node_instance_type = "t3.medium"   # small for dev
  min_nodes          = 1
  max_nodes          = 3
}
HCL
# environments/prod/main.tf
module "network" {
  source      = "../../modules/network"
  vpc_cidr    = "10.0.0.0/16"    # prod CIDR
  environment = "prod"
}

module "eks" {
  source             = "../../modules/eks"
  vpc_id             = module.network.vpc_id
  subnet_ids         = module.network.private_subnet_ids
  node_instance_type = "m5.2xlarge"  # large for prod
  min_nodes          = 5
  max_nodes          = 20
}

How do workspaces and per-environment roots compare?

| Aspect | Workspaces | Per-environment roots |
|---|---|---|
| State isolation | Same backend, different keys | Completely separate backends |
| Code sharing | Same code for all | Shared modules, thin roots |
| Accidental cross-env | Easy (wrong workspace) | Hard (separate directory) |
| Blast radius | All envs share one config | Each env is independent |
| Best for | Ephemeral sandboxes | Dev/staging/prod separation |

What are common mistakes?

The most common mistake is copying entire configurations into three folders instead of using shared modules. The copies drift, and staging no longer matches production.

BASH
# WRONG - copy-paste configuration per environment
terraform/
  dev/main.tf      # full copy of all resources
  staging/main.tf  # diverges over time
  prod/main.tf     # different from staging -> bugs slip past

# CORRECT - shared modules, thin per-environment roots
terraform/
  modules/         # shared logic, single source of truth
  environments/
    dev/main.tf    # calls modules with dev values
    prod/main.tf   # calls modules with prod values

What should a senior engineer know?

Modules should be versioned when published to a registry. Pin the version in the caller so that an upstream module change does not silently affect production.

The composition pattern gives strong isolation: each environment has its own locked, versioned state and its own blast radius, while the shared modules guarantee the environments stay structurally consistent.

BASH
# Apply only the prod environment
cd environments/prod
terraform init
terraform plan -var-file=prod.tfvars
terraform apply -var-file=prod.tfvars
# This can NEVER touch dev or staging state

Visual Overview

DIAGRAM
  Module Composition Pattern
  ===========================

  modules/                        (shared, reusable)
  ├── network/                    VPC, subnets, routing
  │     variables.tf, main.tf, outputs.tf
  ├── eks/                        EKS cluster, node groups
  └── database/                   RDS instance

  environments/
  ├── dev/                        (thin root, own state)
  │     main.tf  ─→ calls modules with dev values
  │     backend.tf ─→ state: dev/terraform.tfstate
  │
  ├── staging/                    (thin root, own state)
  │     main.tf  ─→ calls modules with staging values
  │     backend.tf ─→ state: staging/terraform.tfstate
  │
  └── prod/                       (thin root, own state)
        main.tf  ─→ calls modules with prod values
        backend.tf ─→ state: prod/terraform.tfstate


  Data Flow
  ==========

  modules/network
       │ outputs: vpc_id, subnet_ids
       ▼
  modules/eks
       │ inputs: vpc_id, subnet_ids
       │ outputs: cluster_endpoint
       ▼
  modules/database
       │ inputs: subnet_ids
       │ outputs: db_endpoint
       ▼
  Application connects to cluster_endpoint and db_endpoint

Key Takeaway

A Terraform module is a reusable, parameterized container of resources that you define once and instantiate many times with different inputs. Good modules have typed variables, clear outputs, a single responsibility, and are versioned when published. They keep infrastructure code DRY and structurally consistent across environments.

A workspace is a named state copy of one configuration. Workspaces are useful for ephemeral sandboxes, but for separating dev, staging, and production the safer pattern is per-environment root configurations that compose shared modules, each with its own isolated backend state.

The composition pattern gives strong isolation: each environment has its own locked, versioned state and its own blast radius, while the shared modules guarantee the environments stay structurally identical. A mistake in dev can never touch production state.

Remember one sentence: share logic in modules, isolate environments in per-environment roots with separate state, and reserve workspaces for lightweight sandboxes.

---

Share
Use ← → arrow keys to navigate