Terraform modules are the hidden glue that makes managing complex infrastructure, like ECS services, a dream instead of a nightmare.
Let’s see this in action. Imagine you’ve got a microservice, "user-auth," that needs to run on ECS. Here’s a simplified Terraform module for it:
# modules/ecs-service/main.tf
resource "aws_ecs_cluster" "this" {
cluster_name = var.cluster_name
}
resource "aws_ecs_task_definition" "this" {
family = var.task_family
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.task_cpu
memory = var.task_memory
execution_role_arn = aws_iam_role.ecs_execution_role.arn
task_role_arn = aws_iam_role.ecs_task_role.arn
container_definitions = jsonencode([
{
name = var.container_name
image = var.container_image
essential = true
portMappings = [
{
containerPort = var.container_port
hostPort = var.container_port
protocol = "tcp"
}
]
environment = var.container_environment
}
])
}
resource "aws_iam_role" "ecs_execution_role" {
name = "${var.cluster_name}-ecs-execution-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
}
resource "aws_iam_policy_attachment" "ecs_execution_role_policy" {
role = aws_iam_role.ecs_execution_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role" "ecs_task_role" {
name = "${var.cluster_name}-ecs-task-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ecs-tasks.amazonaws.com"
}
}
]
})
}
# You'd attach more specific task-related policies here, e.g., S3 access
resource "aws_ecs_service" "this" {
name = var.service_name
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.this.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = var.subnets
security_groups = var.security_groups
assign_public_ip = true
}
load_balancer {
target_group_arn = aws_lb_target_group.this.arn
container_name = var.container_name
container_port = var.container_port
}
depends_on = [
aws_iam_role_policy_attachment.ecs_execution_role_policy
]
}
resource "aws_lb_target_group" "this" {
name = "${var.service_name}-tg"
port = var.container_port
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = "/"
protocol = "HTTP"
matcher = "200-299"
interval = 30
timeout = 5
healthy_threshold = 2
unhealthy_threshold = 2
}
}
resource "aws_lb" "this" {
name = "${var.service_name}-alb"
internal = false
load_balancer_type = "application"
subnets = var.subnets
security_groups = var.security_groups
}
resource "aws_lb_listener" "this" {
load_balancer_arn = aws_lb.this.arn
port = "80"
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.this.arn
}
}
variable "cluster_name" {
description = "The name of the ECS cluster."
type = string
}
variable "task_family" {
description = "The family for the ECS task definition."
type = string
}
variable "task_cpu" {
description = "The number of CPU units to reserve for the task."
type = string
default = "256" # 0.25 vCPU
}
variable "task_memory" {
description = "The amount of memory (in MiB) to reserve for the task."
type = string
default = "512"
}
variable "container_name" {
description = "The name of the container within the task definition."
type = string
}
variable "container_image" {
description = "The Docker image to use for the container."
type = string
}
variable "container_port" {
description = "The port the container listens on."
type = number
}
variable "container_environment" {
description = "Environment variables to pass to the container."
type = map(string)
default = {}
}
variable "service_name" {
description = "The name of the ECS service."
type = string
}
variable "desired_count" {
description = "The desired number of tasks to run."
type = number
default = 1
}
variable "vpc_id" {
description = "The VPC ID where resources will be deployed."
type = string
}
variable "subnets" {
description = "List of subnet IDs for the service."
type = list(string)
}
variable "security_groups" {
description = "List of security group IDs for the service."
type = list(string)
}
Now, to deploy our "user-auth" service, we’d use this module in our main Terraform configuration:
# main.tf
provider "aws" {
region = "us-east-1"
}
module "user_auth_service" {
source = "./modules/ecs-service"
cluster_name = "my-app-cluster"
task_family = "user-auth-task"
container_name = "auth-api"
container_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/user-auth:latest"
container_port = 8080
container_environment = {
DATABASE_URL = "postgres://user:password@db.example.com:5432/auth"
JWT_SECRET = "supersecretkey"
}
service_name = "user-auth"
desired_count = 2
vpc_id = "vpc-0123456789abcdef0"
subnets = ["subnet-0123456789abcdef0", "subnet-0abcdef0123456789"]
security_groups = ["sg-0123456789abcdef0"]
}
module "product_catalog_service" {
source = "./modules/ecs-service"
cluster_name = "my-app-cluster" # Same cluster
task_family = "product-catalog-task"
container_name = "catalog-api"
container_image = "123456789012.dkr.ecr.us-east-1.amazonaws.com/product-catalog:v1.2.0"
container_port = 3000
service_name = "product-catalog"
desired_count = 1
vpc_id = "vpc-0123456789abcdef0"
subnets = ["subnet-0123456789abcdef0", "subnet-0abcdef0123456789"]
security_groups = ["sg-0123456789abcdef0"]
}
This structure solves the problem of repetitive configuration. Instead of copying and pasting dozens of lines of HCL for each new microservice running on ECS, you define the common patterns once in the module and then instantiate it with different variables. This makes your infrastructure code DRY (Don’t Repeat Yourself), easier to read, and much less prone to copy-paste errors.
Internally, a Terraform module is just a collection of .tf files treated as a unit. When you use source = "./modules/ecs-service", Terraform looks for those files and treats them as a distinct resource block. The variable blocks in the module are the public interface – the knobs and dials you can turn when you use the module. The resource blocks are the actual AWS infrastructure components being created.
The real power comes from composing modules. Notice how both user_auth_service and product_catalog_service use the same ./modules/ecs-service source. They share the underlying logic for creating an ECS service, task definition, IAM roles, and a load balancer, but they are distinct deployments with different container images, ports, and environment variables. You can even define a "networking" module, a "database" module, and an "ecs-service" module and compose them together to build your entire application stack.
The one thing most people don’t realize is that you can source modules from remote locations like GitHub repositories, Terraform Registry, or even other S3 buckets. This enables truly collaborative infrastructure management, where teams can publish and share reusable modules, standardizing deployments across an organization. For instance, you could have a github.com/my-org/terraform-aws-ecs-service module that everyone uses.
The next step is often to manage the state of these modules and their dependencies more effectively, especially when dealing with multiple environments or teams.