Back to Code & Architecture

Document not found, overwrite this content with #not-found slot in <ContentDoc>.

OpenTofu Modular Infrastructure

After migrating from Terraform to OpenTofu (the open-source fork), I built a library of reusable modules that let me spin up entire environments — VPC, ECS cluster, databases, monitoring — in a single tofu apply.

Why OpenTofu?

  • Truly open source — BUSL license concerns with Terraform eliminated
  • Drop-in compatible — Same HCL syntax, same providers, zero rewrite needed
  • Community-driven — Backed by the Linux Foundation

Module Architecture

VPC Module

# modules/vpc/main.tf
variable "environment" {
  type = string
}

variable "vpc_cidr" {
  type    = string
  default = "10.0.0.0/16"
}

variable "availability_zones" {
  type    = list(string)
  default = ["ap-southeast-1a", "ap-southeast-1b", "ap-southeast-1c"]
}

locals {
  public_subnets  = [for i, az in var.availability_zones : cidrsubnet(var.vpc_cidr, 8, i)]
  private_subnets = [for i, az in var.availability_zones : cidrsubnet(var.vpc_cidr, 8, i + 10)]
}

resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
    ManagedBy   = "opentofu"
  }
}

resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = local.public_subnets[count.index]
  availability_zone = var.availability_zones[count.index]

  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.environment}-public-${var.availability_zones[count.index]}"
    Environment = var.environment
    Tier        = "public"
  }
}

resource "aws_subnet" "private" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = local.private_subnets[count.index]
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name        = "${var.environment}-private-${var.availability_zones[count.index]}"
    Environment = var.environment
    Tier        = "private"
  }
}

# NAT Gateway (single for cost savings, multi-AZ for production)
resource "aws_eip" "nat" {
  count  = var.environment == "production" ? length(var.availability_zones) : 1
  domain = "vpc"
}

resource "aws_nat_gateway" "main" {
  count         = var.environment == "production" ? length(var.availability_zones) : 1
  allocation_id = aws_eip.nat[count.index].id
  subnet_id     = aws_subnet.public[count.index].id
}

output "vpc_id" {
  value = aws_vpc.main.id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

ECS Service Module

# modules/ecs-service/main.tf
variable "cluster_id" { type = string }
variable "service_name" { type = string }
variable "image" { type = string }
variable "cpu" { type = number, default = 256 }
variable "memory" { type = number, default = 512 }
variable "desired_count" { type = number, default = 2 }
variable "min_count" { type = number, default = 1 }
variable "max_count" { type = number, default = 10 }
variable "port" { type = number, default = 8080 }
variable "subnet_ids" { type = list(string) }
variable "security_group_ids" { type = list(string) }
variable "target_group_arn" { type = string }

resource "aws_ecs_task_definition" "service" {
  family                   = var.service_name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.cpu
  memory                   = var.memory
  execution_role_arn       = aws_iam_role.execution.arn
  task_role_arn            = aws_iam_role.task.arn

  container_definitions = jsonencode([{
    name  = var.service_name
    image = var.image
    portMappings = [{
      containerPort = var.port
      protocol      = "tcp"
    }]
    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"         = aws_cloudwatch_log_group.service.name
        "awslogs-region"        = data.aws_region.current.name
        "awslogs-stream-prefix" = "ecs"
      }
    }
    environment = [
      { name = "OTEL_EXPORTER_OTLP_ENDPOINT", value = "http://otel-collector:4317" },
      { name = "OTEL_SERVICE_NAME", value = var.service_name },
    ]
  }])
}

resource "aws_ecs_service" "service" {
  name            = var.service_name
  cluster         = var.cluster_id
  task_definition = aws_ecs_task_definition.service.arn
  desired_count   = var.desired_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = var.subnet_ids
    security_groups = var.security_group_ids
  }

  load_balancer {
    target_group_arn = var.target_group_arn
    container_name   = var.service_name
    container_port   = var.port
  }

  deployment_circuit_breaker {
    enable   = true
    rollback = true
  }

  lifecycle {
    ignore_changes = [desired_count]
  }
}

# Autoscaling
resource "aws_appautoscaling_target" "service" {
  max_capacity       = var.max_count
  min_capacity       = var.min_count
  resource_id        = "service/${split("/", var.cluster_id)[1]}/${aws_ecs_service.service.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

resource "aws_appautoscaling_policy" "cpu" {
  name               = "${var.service_name}-cpu"
  policy_type        = "TargetTrackingScaling"
  resource_id        = aws_appautoscaling_target.service.resource_id
  scalable_dimension = aws_appautoscaling_target.service.scalable_dimension
  service_namespace  = aws_appautoscaling_target.service.service_namespace

  target_tracking_scaling_policy_configuration {
    target_value       = 60.0
    scale_in_cooldown  = 300
    scale_out_cooldown = 60
    predefined_metric_specification {
      predefined_metric_type = "ECSServiceAverageCPUUtilization"
    }
  }
}

Usage — Spinning Up an Environment

# environments/production/main.tf
module "vpc" {
  source      = "../../modules/vpc"
  environment = "production"
  vpc_cidr    = "10.0.0.0/16"
}

module "ecs_cluster" {
  source      = "../../modules/ecs-cluster"
  environment = "production"
  vpc_id      = module.vpc.vpc_id
  subnet_ids  = module.vpc.public_subnet_ids
}

module "api_service" {
  source             = "../../modules/ecs-service"
  cluster_id         = module.ecs_cluster.cluster_id
  service_name       = "api"
  image              = "123456789.dkr.ecr.ap-southeast-1.amazonaws.com/api:latest"
  cpu                = 512
  memory             = 1024
  desired_count      = 3
  min_count          = 2
  max_count          = 20
  subnet_ids         = module.vpc.private_subnet_ids
  security_group_ids = [module.ecs_cluster.tasks_security_group_id]
  target_group_arn   = module.ecs_cluster.target_group_arn
}

module "rds" {
  source      = "../../modules/rds"
  environment = "production"
  engine      = "mysql"
  instance_class = "db.r6g.large"
  subnet_ids  = module.vpc.private_subnet_ids
  vpc_id      = module.vpc.vpc_id
}

Key Principles

  1. Environment parity — Same modules for staging and production, different variables
  2. Least privilege — Each service gets its own task role with minimum permissions
  3. Encryption everywhere — KMS keys for RDS, S3, and EBS by default
  4. OpenTelemetry baked in — Every service container gets OTEL env vars automatically
  5. Circuit breakers — Deployment failures auto-rollback without intervention

Migration from Terraform

The migration was seamless:

# 1. Install OpenTofu
brew install opentofu

# 2. Initialize (uses same .terraform.lock.hcl)
tofu init

# 3. Import existing state
tofu state pull > state.json
# (state format is identical)

# 4. Plan to verify no changes
tofu plan
# No changes. Your infrastructure matches the configuration.

Zero infrastructure changes required. Just a tool swap.