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...
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 help with cloud infrastructure?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.