~ / work / ecs-cicd-module

One module, entire microservice platform_

A Terraform module that turns ~50 lines of configuration into a production-grade ECS Fargate platform — load balancing, blue/green deployments, service discovery, and an independent CI/CD pipeline for every microservice.

role
author & maintainer
stack
Terraform, ECS Fargate, ALB, CodePipeline, CodeBuild, CodeDeploy, ECR, CloudFront, ACM, Secrets Manager
distribution
versioned module in a private Terraform registry (S3-backed)

the problem

Every team deploying microservices on AWS was solving the same problems from scratch: how to route traffic, how to deploy without downtime, how to wire a pipeline, how to handle internal service-to-service calls. The result was a dozen slightly-different, slightly-broken platforms — and weeks of lead time for every new service.

the design

One shared ECS cluster, many services — each declared as a few lines in a map. The module classifies every service into one of three shapes and builds the right infrastructure for each:

                    ┌──────────────────────────────────────────────┐
                    │                 AWS Account                  │
                    │                                              │
 Internet/CDN ─────►│  ALB (external)                              │
                    │   ├── /            ──► ui     [blue/green]   │
                    │   ├── api.domain   ──► api    [host routing] │
                    │   └── /admin/*     ──► admin  [path routing] │
                    │                                              │
                    │  Service Discovery (*.local)                 │
                    │   ├── ds.ns.local     ──► ds     [rolling +  │
                    │   └── worker.ns.local ──► worker  circuit-brk]│
                    │                                              │
                    │  CI-only (task def, no service)              │
                    │   └── data-processor ──► ad-hoc run-task     │
                    │                                              │
                    │  Shared ECR ◄── CodePipeline (per service)   │
                    │                  └── CodeBuild ─► CodeDeploy │
                    └──────────────────────────────────────────────┘

External services get ALB listeners with host- or path-based routing and CodeDeploy blue/green — zero-downtime by construction. Internal services get Cloud Map service discovery and rolling deploys with a deployment circuit breaker that auto-rolls-back bad releases. CI-only services get a pipeline and task definition without a running service — for batch and ad-hoc workloads.

engineering details

One pipeline per microservice. Every service deploys independently — no lock-step releases, no shared blast radius. A shared ECR repository with service-scoped image tags keeps registry sprawl down.

Templated task definitions. Container definitions, appspec, and buildspec render from templates, including a security-scanning variant (Wiz) that inserts image scanning into the build without teams changing anything.

Batteries included, swappable. Sub-modules for CloudFront (with Lambda@Edge), ACM certificates, Secrets Manager wiring, and synthetic monitoring (Uptrends) — each optional.

Roadmapped like a product. Canary traffic shifting, sidecar injection (OTEL/Envoy/Datadog), and map-based secrets are tracked on a public roadmap — because an internal module is a product with users.

from the code

The heart of the module is one aws_ecs_service resource that shape-shifts per service via for_each and dynamic blocks — external services get an ALB target group, internal ones get Cloud Map service discovery, from the same resource (naming sanitized):

hcl · services.tf (excerpt)
resource "aws_ecs_service" "service" {
  for_each = local.deployed_services      # one map drives everything

  name            = "aws-${var.account_alias}-…-service-${each.key}"
  launch_type     = "FARGATE"
  task_definition = aws_ecs_task_definition.task[each.key].arn

  # ALB wiring only exists for external services
  dynamic "load_balancer" {
    for_each = each.value.service_type == "external" ? [1] : []
    content {
      target_group_arn = aws_lb_target_group.blue[each.key].arn
      container_port   = each.value.container_port
    }
  }

  # internal services register in Cloud Map instead
  dynamic "service_registries" {
    for_each = each.value.service_type == "internal" ? [1] : []
    content { registry_arn = aws_service_discovery_service.sd[each.key].arn }
  }
}

Routing intent lives in data, not resources: routing_type (host vs path), per-service domains, and the default-service fallback all resolve inside locals — so adding a microservice is adding a map entry, never copying a resource block.

impact

New microservice platforms went from weeks of bespoke Terraform to under an hour of writing a service map. Deployments are zero-downtime by default, rollbacks are automatic, and every team runs on the same audited, versioned foundation.

TerraformHCLECS Fargate CodeDeploy Blue/GreenALBCloud Map Platform as a product
← all work next: EKS fleet upgrades →