As your infrastructure grows, you need two things: a way to reuse patterns (modules) and a safe way to track what exists (state).
Terraform Modules
A module is a container for multiple resources that are used together. Every Terraform configuration is technically a module — the root module.
Why Modules?
Without modules, your code becomes a massive flat file. Modules let you:
- Reuse the same pattern across environments (dev, staging, prod)
- Encapsulate complexity behind a simple interface
- Standardize infrastructure across teams
- Test components independently
Module Structure
modules/
└── vpc/
├── main.tf # Resources
├── variables.tf # Inputs
├── outputs.tf # Outputs
└── README.md # Usage docsWriting a Module
modules/vpc/variables.tf:
variable "name" {
type = string
description = "Name prefix for all resources"
}
variable "cidr_block" {
type = string
default = "10.0.0.0/16"
description = "VPC CIDR block"
}
variable "public_subnets" {
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
description = "Public subnet CIDR blocks"
}modules/vpc/main.tf:
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
tags = { Name = "${var.name}-vpc" }
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.name}-public-${count.index}" }
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = { Name = "${var.name}-igw" }
}modules/vpc/outputs.tf:
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}Using a Module
module "production_vpc" {
source = "./modules/vpc"
name = "production"
cidr_block = "10.0.0.0/16"
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
}
module "staging_vpc" {
source = "./modules/vpc"
name = "staging"
cidr_block = "10.1.0.0/16"
public_subnets = ["10.1.1.0/24"]
}
# Reference module outputs
resource "aws_instance" "web" {
subnet_id = module.production_vpc.public_subnet_ids[0]
# ...
}Registry Modules
The Terraform Registry has thousands of pre-built modules:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.16.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.20.0/24"]
enable_nat_gateway = true
}Terraform State
State is how Terraform knows what infrastructure it manages. It's stored in a file called terraform.tfstate.
What State Tracks
{
"resources": [
{
"type": "aws_instance",
"name": "web",
"instances": [
{
"attributes": {
"id": "i-0abc123def456",
"ami": "ami-0c55b159cbfafe1f0",
"instance_type": "t3.micro",
"public_ip": "54.210.100.50"
}
}
]
}
]
}State maps your configuration to real-world resources. Without it, Terraform wouldn't know that aws_instance.web corresponds to i-0abc123def456.
The Problem with Local State
By default, state is stored locally in terraform.tfstate. This breaks down with teams:
| Problem | What Happens |
|---|---|
| No sharing | Only you can run Terraform |
| No locking | Two people apply at once, infrastructure corrupts |
| No backup | Laptop dies, state is gone, orphaned resources everywhere |
| Secrets in state | Passwords and keys stored in a local file |
Remote Backends
Store state remotely so your team can share it safely.
S3 + DynamoDB (AWS):
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}The DynamoDB table provides state locking — only one person can modify state at a time.
Terraform Cloud:
terraform {
cloud {
organization = "my-org"
workspaces {
name = "production"
}
}
}Terraform Cloud handles state storage, locking, encryption, and access control.
State Locking
When someone runs terraform apply, the state is locked:
User A: terraform apply
→ Acquires lock ✓
→ Makes changes
→ Releases lock ✓
User B: terraform apply (during User A's apply)
→ Lock already held ✗
→ "Error: Error acquiring the state lock"
If a lock gets stuck (crashed process), force-unlock it:
terraform force-unlock LOCK_IDState Commands
# List all resources in state
terraform state list
# Show details of a specific resource
terraform state show aws_instance.web
# Move a resource (rename without destroying)
terraform state mv aws_instance.web aws_instance.app
# Remove a resource from state (keeps the real resource)
terraform state rm aws_instance.web
# Import existing infrastructure into state
terraform import aws_instance.web i-0abc123def456State Best Practices
- Always use remote state for team projects
- Enable encryption — state contains secrets
- Enable locking — prevent concurrent modifications
- Never edit state manually — use
terraform statecommands - Separate state per environment — don't put dev and prod in one state file
- Back up state — enable versioning on your S3 bucket
Environment Separation
Use separate state files for each environment:
environments/
├── dev/
│ ├── main.tf # source = "../../modules/..."
│ └── terraform.tfvars
├── staging/
│ ├── main.tf
│ └── terraform.tfvars
└── prod/
├── main.tf
└── terraform.tfvarsEach environment has its own backend configuration and state file. A bug in dev can never corrupt prod state.
Summary
- Modules let you reuse infrastructure patterns with different inputs
- Write modules with
variables.tf,main.tf, andoutputs.tf - Use registry modules for common patterns (VPCs, EKS clusters, etc.)
- State tracks the mapping between your code and real infrastructure
- Always use remote state with locking for team projects
- Separate state per environment to isolate blast radius