Terraform Modules and Workspace Patterns for Real-World Infrastructure
Master Terraform modules, workspaces, and state management. DRY infrastructure code, remote state, module composition, and multi-environment deployment...
One owner, one affected system, and the next buyer or recovery deadline mapped.
Terraform Beyond the Basics
Every Terraform tutorial teaches you to create a main.tf and run apply. Real-world infrastructure needs modules for reusability, workspaces for environments, remote state for team collaboration, and patterns that scale.
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><rect x="60" y="30" width="140" height="140" rx="6" fill="none" stroke="#e2e8f0" stroke-width="1.5"/><text x="130" y="24" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Production</text><rect x="70" y="40" width="120" height="22" rx="3" fill="#6366f1" opacity="0.8"/><circle cx="82" cy="51" r="3" fill="#2dd4bf"/><text x="130" y="55" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Web Server</text><rect x="70" y="68" width="120" height="22" rx="3" fill="#6366f1" opacity="0.8"/><circle cx="82" cy="79" r="3" fill="#2dd4bf"/><text x="130" y="83" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">App Server</text><rect x="70" y="96" width="120" height="22" rx="3" fill="#a855f7" opacity="0.8"/><circle cx="82" cy="107" r="3" fill="#2dd4bf"/><text x="130" y="111" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Database</text><rect x="70" y="124" width="120" height="22" rx="3" fill="#f59e0b" opacity="0.6"/><circle cx="82" cy="135" r="3" fill="#2dd4bf"/><text x="130" y="139" text-anchor="middle" fill="#1a1a2e" font-size="9" font-family="system-ui">Monitoring</text><rect x="290" y="30" width="140" height="140" rx="6" fill="none" stroke="#e2e8f0" stroke-width="1.5"/><text x="360" y="24" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Staging</text><rect x="300" y="40" width="120" height="22" rx="3" fill="#3b82f6" opacity="0.6"/><circle cx="312" cy="51" r="3" fill="#2dd4bf"/><text x="360" y="55" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Web Server</text><rect x="300" y="68" width="120" height="22" rx="3" fill="#3b82f6" opacity="0.6"/><circle cx="312" cy="79" r="3" fill="#2dd4bf"/><text x="360" y="83" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">App Server</text><rect x="300" y="96" width="120" height="22" rx="3" fill="#a855f7" opacity="0.5"/><circle cx="312" cy="107" r="3" fill="#f59e0b"/><text x="360" y="111" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Database</text><line x1="200" y1="100" x2="290" y2="100" stroke="#2dd4bf" stroke-width="1.5" stroke-dasharray="5,3"/><text x="245" y="95" text-anchor="middle" fill="#2dd4bf" font-size="8" font-family="system-ui">VLAN</text><rect x="480" y="60" width="90" height="70" rx="6" fill="none" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,3"/><text x="525" y="85" text-anchor="middle" fill="#f59e0b" font-size="9" font-family="system-ui">Backup</text><text x="525" y="100" text-anchor="middle" fill="#f59e0b" font-size="9" font-family="system-ui">Storage</text><text x="525" y="115" text-anchor="middle" fill="#94a3b8" font-size="8" font-family="system-ui">3-2-1 Rule</text><line x1="430" y1="100" x2="478" y2="95" stroke="#f59e0b" stroke-width="1" stroke-dasharray="4,3"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Server infrastructure: production and staging environments connected via VLAN with offsite backups.</p></div>
Module Architecture
A well-structured Terraform project uses modules to encapsulate reusable components:
infrastructure/
├── environments/
│ ├── production/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── staging/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── development/
│ └── ...
├── modules/
│ ├── networking/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── database/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── application/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── global/
├── dns/
└── iam/Building a Reusable Module
# modules/database/variables.tf
variable "name" {
description = "Database instance name"
type = string
}
variable "engine" {
description = "Database engine (postgres, mysql)"
type = string
default = "postgres"
validation {
condition = contains(["postgres", "mysql"], var.engine)
error_message = "Engine must be postgres or mysql."
}
}
variable "engine_version" {
description = "Database engine version"
type = string
default = "16"
}
variable "instance_class" {
description = "Instance class"
type = string
default = "db.t3.micro"
}
variable "allocated_storage" {
description = "Storage in GB"
type = number
default = 20
}
variable "environment" {
description = "Environment name"
type = string
}
variable "vpc_id" {
description = "VPC ID for security group"
type = string
}
variable "subnet_ids" {
description = "Subnet IDs for DB subnet group"
type = list(string)
}
variable "allowed_cidr_blocks" {
description = "CIDR blocks allowed to connect"
type = list(string)
default = []
}# modules/database/main.tf
resource "aws_db_subnet_group" "this" {
name = "db-subnet-group-{var.name}-{var.environment}"
subnet_ids = var.subnet_ids
tags = {
Name = "db-subnet-group-{var.name}"
Environment = var.environment
}
}
resource "aws_security_group" "db" {
name_prefix = "db-{var.name}-{var.environment}-"
vpc_id = var.vpc_id
ingress {
from_port = var.engine == "postgres" ? 5432 : 3306
to_port = var.engine == "postgres" ? 5432 : 3306
protocol = "tcp"
cidr_blocks = var.allowed_cidr_blocks
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "db-sg-{var.name}"
Environment = var.environment
}
lifecycle {
create_before_destroy = true
}
}
resource "random_password" "db_password" {
length = 32
special = false
}
resource "aws_db_instance" "this" {
identifier = "{var.name}-{var.environment}"
engine = var.engine
engine_version = var.engine_version
instance_class = var.instance_class
allocated_storage = var.allocated_storage
max_allocated_storage = var.allocated_storage * 2
db_name = replace(var.name, "-", "_")
username = "admin"
password = random_password.db_password.result
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.db.id]
backup_retention_period = var.environment == "production" ? 30 : 7
multi_az = var.environment == "production" ? true : false
deletion_protection = var.environment == "production" ? true : false
skip_final_snapshot = var.environment != "production"
final_snapshot_identifier = var.environment == "production" ? "{var.name}-final" : null
tags = {
Name = var.name
Environment = var.environment
ManagedBy = "terraform"
}
}# modules/database/outputs.tf
output "endpoint" {
description = "Database endpoint"
value = aws_db_instance.this.endpoint
}
output "port" {
description = "Database port"
value = aws_db_instance.this.port
}
output "database_name" {
description = "Database name"
value = aws_db_instance.this.db_name
}
output "password" {
description = "Database password"
value = random_password.db_password.result
sensitive = true
}
output "security_group_id" {
description = "Security group ID"
value = aws_security_group.db.id
}Using Modules in Environments
# environments/production/main.tf
module "vpc" {
source = "../../modules/networking"
name = "main"
environment = "production"
cidr_block = "10.0.0.0/16"
}
module "app_database" {
source = "../../modules/database"
name = "app-db"
environment = "production"
engine = "postgres"
engine_version = "16"
instance_class = "db.t3.medium"
allocated_storage = 100
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
allowed_cidr_blocks = [module.vpc.private_cidr]
}
module "analytics_database" {
source = "../../modules/database"
name = "analytics-db"
environment = "production"
engine = "postgres"
engine_version = "16"
instance_class = "db.r6g.large"
allocated_storage = 500
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
allowed_cidr_blocks = [module.vpc.private_cidr]
}<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 170" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="170" rx="12" fill="#1a1a2e"/><path d="M80,90 Q80,50 120,50 Q130,30 160,35 Q190,25 200,50 Q230,45 230,70 Q240,90 210,95 L100,95 Q70,95 80,90 Z" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="155" y="75" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Cloud</text><text x="155" y="120" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">$5,000/mo</text><defs><marker id="arrow9" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto"><path d="M0,0 L10,3.5 L0,7" fill="#2dd4bf"/></marker></defs><line x1="245" y1="70" x2="340" y2="70" stroke="#2dd4bf" stroke-width="2.5" marker-end="url(#arrow9)"/><text x="293" y="60" text-anchor="middle" fill="#2dd4bf" font-size="10" font-family="system-ui" font-weight="bold">Migrate</text><rect x="355" y="35" width="180" height="70" rx="8" fill="none" stroke="#6366f1" stroke-width="2"/><rect x="365" y="45" width="160" height="15" rx="3" fill="#6366f1" opacity="0.7"/><rect x="365" y="65" width="160" height="15" rx="3" fill="#a855f7" opacity="0.7"/><rect x="365" y="85" width="100" height="10" rx="2" fill="#2dd4bf" opacity="0.5"/><text x="445" y="57" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Bare Metal</text><text x="445" y="77" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Docker + LXC</text><text x="445" y="120" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="system-ui">$200/mo</text><text x="300" y="150" text-anchor="middle" fill="#2dd4bf" font-size="11" font-family="system-ui" font-weight="bold">96% cost reduction</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Cloud to self-hosted migration can dramatically reduce infrastructure costs while maintaining full control.</p></div>
Workspaces vs Directories
Terraform offers two approaches for multi-environment management:
Approach 1: Separate Directories (Recommended)
environments/
├── production/ # Own state file, own variables
├── staging/ # Own state file, own variables
└── development/ # Own state file, own variablesEach environment has its own state, reducing the blast radius of mistakes.
Approach 2: Terraform Workspaces
terraform workspace new production
terraform workspace new staging
terraform workspace select production
# In your code
locals {
env_config = {
production = {
instance_class = "db.t3.medium"
min_capacity = 2
multi_az = true
}
staging = {
instance_class = "db.t3.micro"
min_capacity = 1
multi_az = false
}
}
config = local.env_config[terraform.workspace]
}We recommend separate directories because:
Remote State
Never store Terraform state locally for team projects:
# backend.tf
terraform {
backend "s3" {
bucket = "techsaas-terraform-state"
key = "production/infrastructure.tfstate"
region = "ap-south-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}For self-hosted infrastructure, use Infisical or MinIO as an S3-compatible backend, or the pg backend with PostgreSQL:
terraform {
backend "pg" {
conn_str = "postgres://terraform:password@postgres:5432/terraform_state?sslmode=disable"
}
}<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 220" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="220" rx="12" fill="#1a1a2e"/><rect x="200" y="15" width="200" height="40" rx="8" fill="#6366f1"/><text x="300" y="40" text-anchor="middle" fill="#ffffff" font-size="13" font-family="system-ui" font-weight="bold">Orchestrator</text><line x1="250" y1="55" x2="100" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><line x1="300" y1="55" x2="300" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><line x1="350" y1="55" x2="500" y2="90" stroke="#e2e8f0" stroke-width="1.5" stroke-dasharray="4,3"/><rect x="40" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="100" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 1</text><rect x="55" y="120" width="90" height="25" rx="4" fill="#6366f1" opacity="0.7"/><text x="100" y="137" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container A</text><rect x="55" y="150" width="90" height="25" rx="4" fill="#a855f7" opacity="0.7"/><text x="100" y="167" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container B</text><rect x="240" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="300" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 2</text><rect x="255" y="120" width="90" height="25" rx="4" fill="#2dd4bf" opacity="0.7"/><text x="300" y="137" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Container C</text><rect x="255" y="150" width="90" height="25" rx="4" fill="#6366f1" opacity="0.7"/><text x="300" y="167" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container A</text><rect x="440" y="90" width="120" height="110" rx="8" fill="none" stroke="#3b82f6" stroke-width="1.5"/><text x="500" y="110" text-anchor="middle" fill="#3b82f6" font-size="11" font-family="system-ui">Node 3</text><rect x="455" y="120" width="90" height="25" rx="4" fill="#a855f7" opacity="0.7"/><text x="500" y="137" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Container B</text><rect x="455" y="150" width="90" height="25" rx="4" fill="#f59e0b" opacity="0.7"/><text x="500" y="167" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Container D</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Container orchestration distributes workloads across multiple nodes for resilience and scale.</p></div>
Best Practices
1. Pin provider versions: Always specify exact versions to avoid surprises 2. Use data sources: Reference existing resources instead of hardcoding IDs 3. Validate variables: Add validation blocks to catch errors early 4. Tag everything: Environment, team, managed-by, cost-center 5. Use moved blocks: Refactor without destroying resources 6. State locking: Always enable for team environments 7. Module versioning: Tag module releases with semver
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.70"
}
}
}At TechSaaS, we manage our Proxmox infrastructure with Ansible (better for configuration management) and use Terraform for clients who deploy to cloud providers. Our module library covers common patterns: VPC with public/private subnets, RDS with automated backups, ECS services with auto-scaling, and CloudFront distributions. Each module is versioned and documented, so deploying a new environment takes minutes, not days.
Need the next owner and evidence step mapped?
Send the current system and deadline. Yash replies with the service path, first proof artifact, and handoff owner.