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
- Environment parity — Same modules for staging and production, different variables
- Least privilege — Each service gets its own task role with minimum permissions
- Encryption everywhere — KMS keys for RDS, S3, and EBS by default
- OpenTelemetry baked in — Every service container gets OTEL env vars automatically
- 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.