Mark Lowel Montealto — Full Stack Developer & DevOps Engineer

Mark Lowel
Montealto

Full Stack Developer & DevOps Engineer

Blog

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 staging and production creates 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.tf

Writing 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 apply from corrupting state.
  • S3 versioning allows rollback to a previous state snapshot.

Environment Separation: Files vs Workspaces

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/apply per environment explicitly.
  • A staging destroy can 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 plan on PR open; terraform apply on merge. Self-hosted.
  • GitHub Actions: use OIDC (see IAM article) + hashicorp/setup-terraform action.
# .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/production

Refactoring 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 in environments/.
  • Next step: publish reusable modules to the Terraform Registry for cross-team sharing.