Mission 5.2 · Stage 5 — Modules
Design a Reusable ECS Service Module
Goal: One ecs-service module with a typed interface — optional load balancer, optional autoscaling knobs with defaults, env/secret maps — running both the ShopLite API and a new background worker.
By the end of this mission
- Design module inputs with
objecttypes,optional()defaults, andnullfor optional features - Turn maps into ECS environment/secret lists with
forexpressions - Move the existing API into the module with no downtime
- Add a second service by writing ~15 lines instead of ~150
Part 1
Understand it first
Interfaces that are hard to misuse
A module's variables are its API. Good ones are typed precisely (object({...}), not any), grouped by concern (all autoscaling settings in one autoscaling object), and have sensible defaults so the common case is short. Since Terraform 1.3, optional(type, default) inside object types lets callers leave out attributes: autoscaling = { max = 6 } gets min = 1 and cpu_target = 60 automatically.
Optional FEATURES are best expressed as a nullable object: load_balancer = null means 'no load balancer'; passing an object turns it on. Inside the module, dynamic blocks and conditional count react to that.
Maps in, lists out
ECS wants environment as a list of { name, value } objects. Callers find maps much nicer to write: { DB_HOST = ..., PORT = "8080" }. The module converts with a for expression: [for k, v in var.environment : { name = k, value = v }]. Taking maps also rejects duplicate names for free, and the order is always sorted, so reordering keys in the caller never causes a diff.
Composition in the root
The module knows how to run a service; it doesn't know about ShopLite. Roles, the target group, the log group, secrets, and security groups are created in the root (or other modules) and passed in. This keeps the module usable for any service, and keeps the root readable as a map of how the system fits together.
Part 2
Your project after this mission · 9 files change
- app/
- worker.jsnew
- infra/
- alb.tf
- autoscaling.tfdeleted
- backend.tf
- checks.tf
- database.tf
- ecr.tf
- ecs.tfmodified
- iam.tf
- locals.tf
- logs.tfmodified
- main.tf
- outputs.tf
- probe.tf
- providers.tf
- refactors.tfmodified
- security.tf
- storage.tf
- terraform.tfvars
- uploads.tf
- variables.tf
- versions.tf
- modules/
- ecs-service/
- main.tfnew
- outputs.tfnew
- variables.tfnew
- versions.tfnew
- network/
- main.tf
- outputs.tf
- variables.tf
- versions.tf
Part 3
Build it, step by step
- 1
Define the interface
Required inputs have no default.
load_balancerandcommanddefault tonull(feature off).autoscalingdefaults to{}, which withoptional()fills in every attribute. Validations catch invalid Fargate sizes before AWS does.modules/ecs-service/variables.tfwhole filehcl variable "name" { description = "Service and task family name, e.g. shoplite-dev-api." type = string } variable "container_name" { type = string } variable "cluster_id" { type = string } variable "image" { description = "Full image reference including tag." type = string } variable "command" { description = "Override the image's CMD. null = use the image default." type = list(string) default = null } variable "cpu" { type = number default = 256 validation { condition = contains([256, 512, 1024, 2048, 4096], var.cpu) error_message = "cpu must be a valid Fargate size: 256, 512, 1024, 2048, or 4096." } } variable "memory" { type = number default = 512 } variable "execution_role_arn" { type = string } variable "task_role_arn" { type = string } variable "subnet_ids" { type = list(string) } variable "security_group_ids" { type = list(string) } variable "log_group_name" { type = string } variable "environment" { description = "Plain environment variables (name => value)." type = map(string) default = {} } variable "secrets" { description = "Secret environment variables (name => Secrets Manager/SSM ARN, optionally with :jsonkey::)." type = map(string) default = {} } variable "load_balancer" { description = "Attach to an ALB target group. null = no load balancer (workers)." type = object({ target_group_arn = string container_port = number resource_label = optional(string) # enables request-count scaling }) default = null } variable "autoscaling" { type = object({ min = optional(number, 1) max = optional(number, 2) cpu_target = optional(number, 60) requests_per_target = optional(number) }) default = {} } - 2
Implement it
Everything from Missions 3.3 and 3.4, generalised.
merge()addscommandandportMappingsto the container only when relevant, instead of sendingnulls to ECS. Theload_balancerblock is dynamic. The request-count policy exists only when aresource_labeland target are given.modules/ecs-service/main.tfwhole filehcl data "aws_region" "current" {} locals { container = merge( { name = var.container_name image = var.image essential = true environment = [for k, v in var.environment : { name = k, value = v }] secrets = [for k, v in var.secrets : { name = k, valueFrom = v }] logConfiguration = { logDriver = "awslogs" options = { "awslogs-group" = var.log_group_name "awslogs-region" = data.aws_region.current.region "awslogs-stream-prefix" = var.container_name } } }, var.command == null ? {} : { command = var.command }, var.load_balancer == null ? {} : { portMappings = [{ containerPort = var.load_balancer.container_port, protocol = "tcp" }] }, ) } resource "aws_ecs_task_definition" "this" { family = var.name requires_compatibilities = ["FARGATE"] network_mode = "awsvpc" cpu = var.cpu memory = var.memory execution_role_arn = var.execution_role_arn task_role_arn = var.task_role_arn container_definitions = jsonencode([local.container]) runtime_platform { operating_system_family = "LINUX" cpu_architecture = "ARM64" } } resource "aws_ecs_service" "this" { name = var.name cluster = var.cluster_id task_definition = aws_ecs_task_definition.this.arn desired_count = var.autoscaling.min launch_type = "FARGATE" enable_execute_command = true wait_for_steady_state = true health_check_grace_period_seconds = var.load_balancer == null ? null : 30 deployment_minimum_healthy_percent = 100 deployment_maximum_percent = 200 deployment_circuit_breaker { enable = true rollback = true } network_configuration { subnets = var.subnet_ids security_groups = var.security_group_ids assign_public_ip = false } dynamic "load_balancer" { for_each = var.load_balancer == null ? [] : [var.load_balancer] content { target_group_arn = load_balancer.value.target_group_arn container_name = var.container_name container_port = load_balancer.value.container_port } } lifecycle { ignore_changes = [desired_count] } } resource "aws_appautoscaling_target" "this" { service_namespace = "ecs" scalable_dimension = "ecs:service:DesiredCount" resource_id = "service/${split("/", var.cluster_id)[1]}/${aws_ecs_service.this.name}" min_capacity = var.autoscaling.min max_capacity = var.autoscaling.max } resource "aws_appautoscaling_policy" "cpu" { name = "${var.name}-cpu" policy_type = "TargetTrackingScaling" service_namespace = aws_appautoscaling_target.this.service_namespace scalable_dimension = aws_appautoscaling_target.this.scalable_dimension resource_id = aws_appautoscaling_target.this.resource_id target_tracking_scaling_policy_configuration { target_value = var.autoscaling.cpu_target scale_out_cooldown = 30 scale_in_cooldown = 180 predefined_metric_specification { predefined_metric_type = "ECSServiceAverageCPUUtilization" } } } resource "aws_appautoscaling_policy" "requests" { count = try(var.load_balancer.resource_label, null) != null && var.autoscaling.requests_per_target != null ? 1 : 0 name = "${var.name}-requests" policy_type = "TargetTrackingScaling" service_namespace = aws_appautoscaling_target.this.service_namespace scalable_dimension = aws_appautoscaling_target.this.scalable_dimension resource_id = aws_appautoscaling_target.this.resource_id target_tracking_scaling_policy_configuration { target_value = var.autoscaling.requests_per_target scale_out_cooldown = 30 scale_in_cooldown = 180 predefined_metric_specification { predefined_metric_type = "ALBRequestCountPerTarget" resource_label = var.load_balancer.resource_label } } } - 3
Replace the API's resources with a module call
infra/ecs.tfshrinks to the cluster plus a module call. The servicenameandcontainer_namestay exactly as before; changing them would force a replacement.outputs.tfin the module exposesservice_nameandtask_definition_arn. Deleteinfra/autoscaling.tf, since that logic now lives in the module.infra/ecs.tfwhole filehcl resource "aws_ecs_cluster" "main" { name = local.name_prefix setting { name = "containerInsights" value = "enabled" } } locals { image = "${aws_ecr_repository.api.repository_url}:${var.image_tag}" db_secret_arn = aws_db_instance.main.master_user_secret[0].secret_arn db_env = { DB_HOST = aws_db_instance.main.address DB_NAME = aws_db_instance.main.db_name } db_secrets = { DB_USER = "${local.db_secret_arn}:username::" DB_PASSWORD = "${local.db_secret_arn}:password::" } } module "api" { source = "../modules/ecs-service" name = "${local.name_prefix}-api" container_name = "api" cluster_id = aws_ecs_cluster.main.id image = local.image cpu = var.api_cpu memory = var.api_memory execution_role_arn = aws_iam_role.ecs_execution.arn task_role_arn = aws_iam_role.api_task.arn subnet_ids = module.network.private_subnet_ids security_group_ids = [aws_security_group.app.id] log_group_name = aws_cloudwatch_log_group.api.name environment = merge(local.db_env, { PORT = tostring(var.app_port) APP_VERSION = var.image_tag UPLOADS_BUCKET = aws_s3_bucket.uploads.bucket }) secrets = local.db_secrets load_balancer = { target_group_arn = aws_lb_target_group.api.arn container_port = var.app_port resource_label = "${aws_lb.main.arn_suffix}/${aws_lb_target_group.api.arn_suffix}" } autoscaling = { min = 2, max = 6, requests_per_target = 500 } depends_on = [aws_lb_listener.http] } - 4
Move the API's state into the module
Five moved blocks: task definition, service, scaling target, and both policies. The request policy goes from a single resource to
countindex[0]inside the module.infra/refactors.tfadd to filehcl # 2026-09: API moved into modules/ecs-service moved { from = aws_ecs_task_definition.api to = module.api.aws_ecs_task_definition.this } moved { from = aws_ecs_service.api to = module.api.aws_ecs_service.this } moved { from = aws_appautoscaling_target.api to = module.api.aws_appautoscaling_target.this } moved { from = aws_appautoscaling_policy.api_cpu to = module.api.aws_appautoscaling_policy.cpu } moved { from = aws_appautoscaling_policy.api_requests to = module.api.aws_appautoscaling_policy.requests[0] }terminal$ terraform init && terraform plan── expected output ──# aws_ecs_service.api has moved to module.api.aws_ecs_service.this...# aws_ecs_task_definition.api has moved to module.api.aws_ecs_task_definition.this...Plan: 0 to add, 0 to change, 0 to destroy.If your plan instead shows a new task definition revision only because environment variables are now sorted alphabetically, that's a harmless no-op deploy. Apply it. - 5
Add a worker in 20 lines
The worker is a background process: same image, different command, no load balancer, one task. Its
worker.jsconnects to the same database and logs a heartbeat with the product count every minute, which is a placeholder for real jobs like cleaning abandoned uploads. It gets its own log group, so API and worker logs don't mix.infra/ecs.tfadd to filehcl resource "aws_cloudwatch_log_group" "worker" { name = "/${var.project}/${var.environment}/worker" retention_in_days = 14 } module "worker" { source = "../modules/ecs-service" name = "${local.name_prefix}-worker" container_name = "worker" cluster_id = aws_ecs_cluster.main.id image = local.image command = ["node", "worker.js"] execution_role_arn = aws_iam_role.ecs_execution.arn task_role_arn = aws_iam_role.api_task.arn subnet_ids = module.network.private_subnet_ids security_group_ids = [aws_security_group.app.id] log_group_name = aws_cloudwatch_log_group.worker.name environment = merge(local.db_env, { APP_VERSION = var.image_tag }) secrets = local.db_secrets autoscaling = { min = 1, max = 1 } } - 6
Ship 1.4.0 with the worker and apply
Add
app/worker.js, build and push1.4.0, set it in tfvars, and apply. Both services roll to the new image. The API is unchanged apart from the version; the worker is new. Also addCOPY worker.js ./next toserver.jsin the Dockerfile.app/worker.jswhole filejs import fs from "node:fs"; import pg from "pg"; const pool = new pg.Pool({ host: process.env.DB_HOST, database: process.env.DB_NAME, user: process.env.DB_USER, password: process.env.DB_PASSWORD, max: 2, ssl: { ca: fs.readFileSync("/app/rds-ca.pem", "utf8") }, }); console.log(`worker ${process.env.APP_VERSION ?? "dev"} started`); setInterval(async () => { try { await pool.query("select 1"); console.log("heartbeat: db ok"); } catch (err) { console.error("heartbeat: db error", err.message); } }, 60_000); process.on("SIGTERM", async () => { await pool.end(); process.exit(0); });terminal$ docker buildx build --platform linux/arm64 -t "$REPO:1.4.0" --push ../appterraform applyaws logs tail /shoplite/dev/worker --since 5m── expected output ──Plan: 6 to add, 1 to change, 1 to destroy....Apply complete! Resources: 6 added, 1 changed, 1 destroyed.2026-09-26T15:21:04 worker/worker/1c2d... worker 1.4.0 started2026-09-26T15:22:04 worker/worker/1c2d... heartbeat: db ok
Checkpoint — you should now have
- ✓
modules/ecs-servicehas typed variables withoptional()defaults and no provider block. - ✓The API moved into
module.apiwith a moves-only plan. - ✓
module.workerruns one task with no load balancer and logs heartbeats. - ✓Adding a service now takes ~20 lines of root code.
Part 4
Break it on purpose
Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.
Break #1
Pass the wrong type
Call the worker with autoscaling = { min = "one" } and run terraform validate.
Break #2
Configure a provider inside the module
Add provider "aws" { region = "ap-south-1" } to modules/ecs-service/main.tf. The worker call already has no count, so first add count = 1 to the worker's module block (as if you wanted to make it optional), then run terraform validate.
Part 5
Interview questions from this mission
How do you design a Terraform module's inputs so they're easy to use correctly?
How do you include a nested block only when an input is set?
Why convert map(string) inputs into ECS's list-of-objects format inside the module?