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.

Want structured learning?

Take the full Ecs course →