May 2026
·2 min read
Terraform Modules & Remote State: Structuring Real Infrastructure
Stop copy-pasting Terraform across environments. Use modules for reusable infrastructure, remote state with S3+DynamoDB, and workspaces for environment separation.
Introduction
- Beyond
main.tf: as infrastructure grows, a single file becomes unmanageable and copy-paste across envs breeds drift. - This article covers modules (reusable infrastructure components), remote state (shared, locked state), and patterns for staging/production separation.
- Assumes familiarity with Terraform basics (see: Terraform beginner article).
The Problem with Flat Terraform Files
- Copy-pasting the same ECS/RDS config for
stagingandproductioncreates divergence over time. - No shared state means Terraform doesn't know what other parts of your team have deployed.
- Local state is fine for solo learners; it breaks for teams.
Terraform Modules
A module is any directory with .tf files. Extract repeated infrastructure into a module, parameterise it, and call it from root configs.
Module Structure
modules/
ecs-service/
main.tf ← ECS task definition + service
variables.tf ← input variables
outputs.tf ← exported values
rds-postgres/
main.tf
variables.tf
outputs.tfWriting a Module (modules/ecs-service/main.tf)
variable "app_name" { type = string }
variable "image_uri" { type = string }
variable "desired_count" { type = number; default = 2 }
variable "cpu" { type = number; default = 256 }
variable "memory" { type = number; default = 512 }
resource "aws_ecs_task_definition" "this" {
family = var.app_name
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.cpu
memory = var.memory
container_definitions = jsonencode([{
name = var.app_name
image = var.image_uri
portMappings = [{ containerPort = 3000 }]
}])
}
resource "aws_ecs_service" "this" {
name = var.app_name
cluster = data.aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.this.arn
desired_count = var.desired_count
launch_type = "FARGATE"
}
output "service_name" { value = aws_ecs_service.this.name }Calling the Module
# environments/production/main.tf
module "frontend" {
source = "../../modules/ecs-service"
app_name = "frontend-prod"
image_uri = "123456789.dkr.ecr.ap-southeast-1.amazonaws.com/frontend:latest"
desired_count = 3
cpu = 512
memory = 1024
}Remote State with S3 + DynamoDB
Bootstrap the State Backend (one-time)
# bootstrap/main.tf — apply this first, manually
resource "aws_s3_bucket" "tfstate" {
bucket = "my-org-terraform-state"
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration { status = "Enabled" }
}
resource "aws_dynamodb_table" "tflock" {
name = "terraform-locks"
billing_mode = "PAY_PER_REQUEST"
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}Configure the Backend
# environments/production/backend.tf
terraform {
backend "s3" {
bucket = "my-org-terraform-state"
key = "production/terraform.tfstate"
region = "ap-southeast-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}- DynamoDB lock prevents concurrent
terraform applyfrom corrupting state. - S3 versioning allows rollback to a previous state snapshot.
Environment Separation: Files vs Workspaces
Option A: Separate State Keys per Environment (Recommended)
environments/
staging/
main.tf ← calls same modules, different vars
backend.tf ← key = "staging/terraform.tfstate"
terraform.tfvars
production/
main.tf
backend.tf ← key = "production/terraform.tfstate"
terraform.tfvars- Cleanest isolation;
plan/applyper environment explicitly. - A staging
destroycan never touch production state.
Option B: Terraform Workspaces
terraform workspace new staging
terraform workspace new production
terraform workspace select staging
terraform apply -var-file=staging.tfvars- Workspaces share the same backend config but use separate state paths.
- Simple for small teams; avoid for large infrastructure (workspace drift is hard to audit).
Cross-Module State References with terraform_remote_state
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "my-org-terraform-state"
key = "production/networking/terraform.tfstate"
region = "ap-southeast-1"
}
}
resource "aws_ecs_service" "app" {
# Use VPC/subnet IDs from the networking module's state
network_configuration {
subnets = data.terraform_remote_state.networking.outputs.private_subnet_ids
}
}CI/CD Integration with Atlantis or GitHub Actions
- Atlantis: runs
terraform planon PR open;terraform applyon merge. Self-hosted. - GitHub Actions: use OIDC (see IAM article) +
hashicorp/setup-terraformaction.
# .github/workflows/terraform.yml
- name: Terraform Plan
run: terraform plan -var-file=production.tfvars
working-directory: environments/production
- name: Terraform Apply
if: github.ref == 'refs/heads/main'
run: terraform apply -auto-approve -var-file=production.tfvars
working-directory: environments/productionRefactoring Tips
terraform state mv— rename resources in state without destroying/recreating.terraform import— import existing AWS resources into state.moved {}block (Terraform 1.1+) — rename resources safely within the config.
Conclusion
- Modules + remote state are the two architectural decisions that scale Terraform from hobby project to production infrastructure.
- Separate state keys per environment give the strongest isolation guarantees.
- Everything in
modules/should be generic; everything environment-specific lives inenvironments/. - Next step: publish reusable modules to the Terraform Registry for cross-team sharing.