Command Palette

Search for a command to run...

Hectal

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.

60 min + ~$0.01/hour for one worker task 6 steps 2 break-it drills

By the end of this mission

  • Design module inputs with object types, optional() defaults, and null for optional features
  • Turn maps into ECS environment/secret lists with for expressions
  • 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.

One module, two servicesdiagram
Rendering diagram…

Part 2

Your project after this mission · 9 files change

shoplite/
  • 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. 1

    Define the interface

    Required inputs have no default. load_balancer and command default to null (feature off). autoscaling defaults to {}, which with optional() 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. 2

    Implement it

    Everything from Missions 3.3 and 3.4, generalised. merge() adds command and portMappings to the container only when relevant, instead of sending nulls to ECS. The load_balancer block is dynamic. The request-count policy exists only when a resource_label and 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. 3

    Replace the API's resources with a module call

    infra/ecs.tf shrinks to the cluster plus a module call. The service name and container_name stay exactly as before; changing them would force a replacement. outputs.tf in the module exposes service_name and task_definition_arn. Delete infra/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. 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 count index [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. 5

    Add a worker in 20 lines

    The worker is a background process: same image, different command, no load balancer, one task. Its worker.js connects 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. 6

    Ship 1.4.0 with the worker and apply

    Add app/worker.js, build and push 1.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 add COPY worker.js ./ next to server.js in 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 ../app
    terraform apply
    aws 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 started
    2026-09-26T15:22:04 worker/worker/1c2d... heartbeat: db ok

Checkpoint — you should now have

  • ✓modules/ecs-service has typed variables with optional() defaults and no provider block.
  • ✓The API moved into module.api with a moves-only plan.
  • ✓module.worker runs 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.

terminal
$ terraform validate
── what you'll see ──
╷
│ Error: Invalid value for input variable
│
│ on ecs.tf line 71, in module "worker":
│ 71: autoscaling = { min = "one" }
│
│ The given value is not suitable for module.worker.var.autoscaling declared
│ at ../modules/ecs-service/variables.tf:77,1-23: attribute "min": a number
│ is required.
╵

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.

terminal
$ terraform validate
── what you'll see ──
╷
│ Error: Module is incompatible with count, for_each, and depends_on
│
│ on ecs.tf line 58, in module "worker":
│ 58: count = 1
│
│ The module at module.worker is a legacy module which contains its own local
│ provider configurations, and so calls to it may not use the count, for_each,
│ or depends_on arguments.
│
│ If you also control the module "../modules/ecs-service", consider updating
│ this module to instead expect provider configurations to be passed by its
│ caller.
╵

Part 5

Interview questions from this mission

01

How do you design a Terraform module's inputs so they're easy to use correctly?

02

How do you include a nested block only when an input is set?

03

Why convert map(string) inputs into ECS's list-of-objects format inside the module?

0/4 · 0%