Terraform ECS Automation: Fully Automated, HTTPS-Secured Fargate Services (Part 4)
Docker-and-ECR
ByOlaniyi Oladimeji
Terraform ECS Automation: Fully Automated, HTTPS-Secured Fargate Services (Part 4)

Part 3 deployed KloudGov to ECS entirely by manually clicking through the console to build a task definition, launch a service, and wire up a load balancer, one step at a time. It worked, and it made every moving piece of an ECS deployment genuinely visible. Still, it has the same problem manual EC2 deployment had back in the very first series: none of it scales, none of it is repeatable without a human doing it correctly every time, and adding a second state means repeating the entire console workflow from scratch.

This post closes that gap completely. Everything built by hand in Part 3 the task definition, the target group, the ALB, the service, and even the ECS cluster and ECR repositories themselves gets rebuilt in Terraform as fully managed resources, extended to support any number of states through the same for_each pattern used throughout this project, and fronted with HTTPS end to end. Add a state to a variable, run terraform apply, and a fully configured, load-balanced, encrypted, highly available Fargate service comes up automatically, with nothing left depending on a manual console step surviving between sessions.

Why Everything Is a Managed Resource This Time

An earlier draft of this automation treated the ECS cluster, the ECR repositories, and CloudWatch log groups as data sources read-only lookups pointing at resources created manually in earlier posts. That works right up until someone tears the project down between sessions (exactly what Part 3’s own teardown instructions tell you to do) and then tries to re-run this automation expecting those prerequisites to exist still. The result is a terraform apply that fails immediately, because there’s nothing left to look up.

The fix, and the design this post now uses throughout: only the resources that genuinely predate this project’s own lifecycle the ECS execution role and the account’s default VPC stay as data sources. Terraform fully manages everything this project creates and destroys, so a terraform destroy followed by a terraform apply six months later works identically to the first run.

# terraform/main.tf

data "aws_iam_role" "ecs_execution_role" {
  name = "KloudGovECSExecutionRole"
}

data "aws_vpc" "default" {
  default = true
}

data "aws_subnets" "default" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.default.id]
  }
}

KloudGovECSExecutionRole stays a data lookup because it’s a standing, account-level prerequisite from Part 1 and is never destroyed by any teardown step in this project. The default VPC stays a data lookup because Terraform should never own the lifecycle of an account-level default VPC. Both are genuinely stable across sessions, unlike the cluster and the repositories.

Step 1: The ECS Cluster and ECR Repositories, as Managed Resources

# terraform/main.tf

resource "aws_ecs_cluster" "kloudgov" {
  name = "kloudgov-ecs-cluster"

  setting {
    name  = "containerInsights"
    value = "disabled"
  }
}

resource "aws_ecr_repository" "app" {
  name                 = "kloudgov-app"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

resource "aws_ecr_repository" "nginx" {
  name                 = "kloudgov-nginx"
  image_tag_mutability = "MUTABLE"

  image_scanning_configuration {
    scan_on_push = true
  }
}

terraform apply now creates the cluster and both repositories fresh on a first run, and terraform destroy removes them cleanly; no manual console steps required in either direction. scan_on_push = true is a small addition beyond what Part 1’s manual repository creation included; it enables ECR’s built-in image vulnerability scanning automatically on every push, at no extra cost, which is worth having from day one rather than turning on later.

Both container images are shared across every state; Michigan, Florida, and Nevada all run the same kloudgov-app and kloudgov-nginx images, differentiated entirely by environment variables at the task level, not by separate images per tenant.

Step 2: Networking and Security Groups

Two security groups: one for the load balancer, one for the tasks it fronts, with the tasks trusting only the ALB directly. The ALB’s security group needs both HTTP and HTTPS open from the start, since this design includes a redirect from 80 to 443.

# terraform/main.tf

resource "aws_security_group" "ecs_alb_sg" {
  name        = "kloudgov-ecs-alb-sg"
  description = "Allow HTTP and HTTPS to the ECS ALB"
  vpc_id      = data.aws_vpc.default.id

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "kloudgov-ecs-alb-sg"
  }
}

resource "aws_security_group" "ecs_tasks_sg" {
  name        = "kloudgov-ecs-tasks-sg"
  description = "Allow inbound traffic from the ALB only"
  vpc_id      = data.aws_vpc.default.id

  ingress {
    from_port       = 80
    to_port         = 80
    protocol        = "tcp"
    security_groups = [aws_security_group.ecs_alb_sg.id]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "kloudgov-ecs-tasks-sg"
  }
}

The tasks’ security group egress stays open to 0.0.0.0/0 deliberately. Fargate tasks need outbound internet access to pull images from ECR and reach CloudWatch Logs, unless you’ve set up VPC endpoints for both (a worthwhile hardening step beyond the scope of this post, but worth knowing exists).

Step 3: A Dedicated Certificate for the ECS Subdomain

This is the one detail that’s easy to get wrong, so it’s worth being explicit about it upfront. The EC2-based deployment from the earlier series uses michigan.kloudgov.online, a single label under the root domain, covered by a wildcard certificate for *.kloudgov.online. This ECS deployment intentionally uses a different naming pattern, michigan.ecs.kloudgov.online, to keep the two architectures’ endpoints clearly distinct while running side by side under the same hosted zone.

A single-level wildcard does not cover a second level of subdomain. *.kloudgov.online matches michigan.kloudgov.online, but it does not match michigan.ecs.kloudgov.online. TLS wildcards match only one label. This requires its own certificate, requested and validated the same way as before, just scoped one level deeper:

# terraform/main.tf

resource "aws_acm_certificate" "kloudgov_ecs_cert" {
  domain_name       = "*.ecs.${var.domain_name}"
  validation_method = "DNS"

  lifecycle {
    create_before_destroy = true
  }
}

resource "aws_route53_record" "ecs_cert_validation" {
  for_each = {
    for dvo in aws_acm_certificate.kloudgov_ecs_cert.domain_validation_options : dvo.domain_name => {
      name   = dvo.resource_record_name
      record = dvo.resource_record_value
      type   = dvo.resource_record_type
    }
  }

  zone_id = aws_route53_zone.kloudgov.zone_id
  name    = each.value.name
  type    = each.value.type
  records = [each.value.record]
  ttl     = 60
}

resource "aws_acm_certificate_validation" "kloudgov_ecs_cert_validate" {
  certificate_arn         = aws_acm_certificate.kloudgov_ecs_cert.arn
  validation_record_fqdns = [for record in aws_route53_record.ecs_cert_validation : record.fqdn]
}

This reuses aws_route53_zone.kloudgov, the same hosted zone created in the EC2 series’ ALB post, since both naming schemes live under the same root domain. As with the first certificate, expect this to take a few minutes while ACM completes DNS validation.

Step 4: The Shared Application Load Balancer

One ALB, shared across every state’s ECS service, is the same architectural choice made for the EC2-based ALB in the earlier series, for the same reason: host-based routing on a single load balancer scales to any number of tenants without paying for one ALB per state.

# terraform/main.tf

resource "aws_lb" "kloudgov_ecs_alb" {
  name               = "kloudgov-ecs-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.ecs_alb_sg.id]
  subnets            = data.aws_subnets.default.ids

  tags = {
    Name = "kloudgov-ecs-alb"
  }
}

resource "aws_lb_listener" "ecs_https" {
  load_balancer_arn = aws_lb.kloudgov_ecs_alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-2021-06"
  certificate_arn   = aws_acm_certificate_validation.kloudgov_ecs_cert_validate.certificate_arn

  default_action {
    type = "fixed-response"
    fixed_response {
      content_type = "text/plain"
      message_body = "No matching state route"
      status_code  = "404"
    }
  }
}

resource "aws_lb_listener" "ecs_http_redirect" {
  load_balancer_arn = aws_lb.kloudgov_ecs_alb.arn
  port              = 80
  protocol          = "HTTP"

  default_action {
    type = "redirect"

    redirect {
      port        = "443"
      protocol    = "HTTPS"
      status_code = "HTTP_301"
    }
  }
}

The HTTPS listener’s default 404 works the same way as the EC2 series’ ALB: a request with a Host header that doesn’t match any state’s routing rule gets a clean 404 instead of silently falling through to some arbitrary tenant’s service. The HTTP listener does nothing but redirect to HTTPS, exactly mirroring the earlier series’ pattern.

Step 5: CloudWatch Log Groups Per State

Container logs need somewhere to go. Rather than letting ECS create log groups implicitly on first task launch, which requires granting the execution role a broader permission than it otherwise needs, this design creates them explicitly as their own managed resource:

# terraform/main.tf

resource "aws_cloudwatch_log_group" "kloudgov" {
  for_each          = toset(var.states)
  name              = "/ecs/kloudgov-${each.value}"
  retention_in_days = 7
}

Explicit log group creation means the task execution role only ever needs to write to a log group that already exists, a permission AmazonECSTaskExecutionRolePolicy already grants, rather than needing the additional ability to create new log groups on the fly. retention_in_days = 7 keeps costs predictable by automatically expiring old logs rather than retaining them indefinitely by default.

Step 6: Per-State Task Definitions

This is the direct Terraform equivalent of everything configured by hand in Part 3’s task definition screen: two containers, resource allocation, and environment variables now templated once and generated per state.

# terraform/main.tf

resource "aws_ecs_task_definition" "kloudgov_fullstack" {
  for_each = toset(var.states)

  depends_on = [aws_cloudwatch_log_group.kloudgov]

  family                   = "kloudgov-${each.value}-fullstack"
  requires_compatibilities = ["FARGATE"]
  network_mode             = "awsvpc"
  cpu                      = "1024"
  memory                   = "3072"
  execution_role_arn       = data.aws_iam_role.ecs_execution_role.arn
  task_role_arn            = data.aws_iam_role.ecs_execution_role.arn

  container_definitions = jsonencode([
    {
      name      = "app"
      image     = "${aws_ecr_repository.app.repository_url}:latest"
      essential = true
      portMappings = [
        {
          name          = "app-8000-tcp"
          containerPort = 8000
          protocol      = "tcp"
        }
      ]
      environment = [
        { name = "AWS_BUCKET", value = module.aws_kloudgov_infrastructure[each.value].state_s3_bucket },
        { name = "AWS_DYNAMODB_TABLE", value = module.aws_kloudgov_infrastructure[each.value].state_dynamodb_table },
        { name = "AWS_REGION", value = var.region },
        { name = "US_STATE", value = each.value }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = "/ecs/kloudgov-${each.value}"
          "awslogs-region"        = var.region
          "awslogs-stream-prefix" = "app"
        }
      }
    },
    {
      name      = "nginx"
      image     = "${aws_ecr_repository.nginx.repository_url}:latest"
      essential = false
      portMappings = [
        {
          name          = "nginx-80-tcp"
          containerPort = 80
          protocol      = "tcp"
        }
      ]
      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = "/ecs/kloudgov-${each.value}"
          "awslogs-region"        = var.region
          "awslogs-stream-prefix" = "nginx"
        }
      }
    }
  ])

  tags = {
    Name = "kloudgov-${each.value}"
  }
}

A few things worth understanding here, beyond the direct console-to-code translation:

The environment variables reference the existing module outputs directly: module.aws_kloudgov_infrastructure[each.value].state_s3_bucket and state_dynamodb_table are the same outputs Part 2’s Terraform module already produces. There’s no manual copy-pasting of bucket names between Terraform runs and console screens, the way Part 3 required; the automation reads the actual provisioned values straight from state.
depends_on = [aws_cloudwatch_log_group.kloudgov] ensures Terraform creates the log group before any task definition that references it by name. Because the reference uses string interpolation rather than a direct attribute reference, Terraform can’t infer this dependency automatically.
task_role_arn and execution_role_arn both point to the same data source, carrying forward the same reasonable-for-a-lab, not-ideal-for-production simplification flagged in Part 3. A production configuration would typically split these into two separate roles: the execution role would have only ECR/CloudWatch permissions, and the task role would have only the S3/DynamoDB access the application actually needs.

Step 7: Per-State Target Groups and Host-Based Routing

# terraform/main.tf

resource "aws_lb_target_group" "ecs_state_tg" {
  for_each    = toset(var.states)
  name        = "kloudgov-ecs-${each.value}-tg"
  port        = 80
  protocol    = "HTTP"
  vpc_id      = data.aws_vpc.default.id
  target_type = "ip"

  health_check {
    path                = "/"
    healthy_threshold   = 2
    unhealthy_threshold = 3
    interval            = 30
    timeout             = 5
  }

  tags = {
    Name = "kloudgov-ecs-${each.value}"
  }
}

locals {
  ecs_state_priority = zipmap(var.states, range(100, 100 + length(var.states)))
}

resource "aws_lb_listener_rule" "ecs_state_routing" {
  for_each     = toset(var.states)
  listener_arn = aws_lb_listener.ecs_https.arn
  priority     = local.ecs_state_priority[each.value]

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.ecs_state_tg[each.value].arn
  }

  condition {
    host_header {
      values = ["${each.value}.ecs.${var.domain_name}"]
    }
  }
}

resource "aws_route53_record" "ecs_state_dns" {
  for_each = toset(var.states)
  zone_id  = aws_route53_zone.kloudgov.zone_id
  name     = "${each.value}.ecs.${var.domain_name}"
  type     = "A"

  alias {
    name                   = aws_lb.kloudgov_ecs_alb.dns_name
    zone_id                = aws_lb.kloudgov_ecs_alb.zone_id
    evaluate_target_health = true
  }
}

target_type = “ip” is the one detail here that has no equivalent in the EC2-based ALB setup, and it’s worth understanding why it’s mandatory. EC2 target groups register actual instance IDs fixed, long-lived compute you can point a target group at directly. Fargate tasks don’t have that; each task gets an ephemeral IP address inside the VPC, assigned fresh every time the task launches or gets replaced. target_type = “ip” tells the target group to expect IP-based registration instead. As you’ll see in the next step, the ECS service automatically registers and deregisters those IPs as tasks come and go. There’s no manual aws_lb_target_group_attachment resource here, unlike the EC2 series; ECS manages that relationship for you.

Note that the listener rule attaches to aws_lb_listener.ecs_https, not the HTTP listener; routing happens entirely on the encrypted side, with the HTTP listener existing solely to redirect.

Step 8: Per-State ECS Services

This is the piece that actually launches and maintains the running tasks, the direct equivalent of Part 3’s manual “Create service” screen.

# terraform/main.tf

resource "aws_ecs_service" "kloudgov_svc" {
  for_each        = toset(var.states)
  name            = "kloudgov-${each.value}-svc"
  cluster         = aws_ecs_cluster.kloudgov.id
  task_definition = aws_ecs_task_definition.kloudgov_fullstack[each.value].arn
  desired_count   = 2
  launch_type     = "FARGATE"

  network_configuration {
    subnets          = data.aws_subnets.default.ids
    security_groups  = [aws_security_group.ecs_tasks_sg.id]
    assign_public_ip = true
  }

  load_balancer {
    target_group_arn = aws_lb_target_group.ecs_state_tg[each.value].arn
    container_name    = "nginx"
    container_port    = 80
  }

  depends_on = [aws_lb_listener_rule.ecs_state_routing]
}

Two details worth being deliberate about:

assign_public_ip = true is required here because the default subnets in most accounts are public subnets without a NAT gateway. Fargate tasks need a public IP to reach ECR and pull their images, and without a NAT gateway providing outbound internet access from a private subnet, this is the direct path to that same connectivity. A more locked-down production setup would place tasks in private subnets behind a NAT gateway instead, keeping them off the public internet entirely while still allowing outbound access a meaningful hardening step and a reasonable next thing to tackle after this post.
depends_on = [aws_lb_listener_rule.ecs_state_routing] exists because Terraform’s dependency graph doesn’t automatically infer that the service needs its listener rule to exist first; without it, Terraform might try to create the service before the routing that makes it reachable is in place, and the initial health checks would fail unnecessarily.

That desired count of 2 is doing real work; it’s the mechanism providing high availability. Rather than one task handling all traffic, ECS keeps two identical task instances running at all times. If one fails a health check or crashes, ECS replaces it automatically while the other continues serving traffic. Worth remembering: this doubles the Fargate cost flagged back in Part 2.

Step 9: Building and Pushing the Images

Since this configuration creates the ECR repositories fresh, they start empty; the task definitions above reference a latest tag that doesn’t exist until you push it. From your EC2 IDE instance:

aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin <YOUR_ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com

cd kloud-gov-application/src
docker build -t kloudgov-app .
docker tag kloudgov-app:latest <YOUR_ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/kloudgov-app:latest
docker push <YOUR_ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/kloudgov-app:latest

cd ../nginx
docker build -t kloudgov-nginx .
docker tag kloudgov-nginx:latest <YOUR_ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/kloudgov-nginx:latest
docker push <YOUR_ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/kloudgov-nginx:latest

This step only needs to happen once per image version; subsequent Terraform apply runs against the same images don’t require re-pushing, only a new push (and a forced service redeployment) when the application code itself changes.

Step 10: Applying and Validating

cd kloud-gov-infrastructure/terraform
terraform plan
terraform apply -auto-approve

Once applied and the images are pushed, confirm each layer:

ECS console: one service per state, each showing 2/2 tasks running.
Target groups: each state’s target group shows both task IPs registered and healthy.
EC2 console: genuinely zero instances, exactly as in Part 3, just arrived at through Terraform instead of console clicks.

Then, in a browser:

https://michigan.ecs.kloudgov.online

Should load KloudGov over a valid HTTPS connection, served through the fully automated path: ALB, host-based routing, target group, ECS service, task, both containers, with nothing configured by hand. Typing http://should automatically redirect to the secure version.

Step 11: Scaling to Additional States

This is where the entire post earns its keep. Adding a new state is the same one-line change established throughout this project:

variable "states" {
  default = ["michigan", "florida", "nevada"]
}

terraform apply -auto-approve

A new task definition, log group, target group, listener rule, DNS record, and ECS service all generate automatically for each new state the same task definition template, populated with that state’s own S3 bucket and DynamoDB table, deployed, load-balanced, and encrypted without a single console click. Compare this to Part 3, where scaling to a second state meant repeating the entire manual task-definition-and-service workflow by hand, end to end, with every field re-entered by a person.

Tearing Down

terraform destroy -auto-approve

Terraform’s dependency graph now owns the full lifecycle: the ECS services stop and deregister their tasks, the task definitions get deregistered, the target groups and ALB come down, the certificates and DNS records are removed, and unlike the earlier version of this automation, the ECS cluster and both ECR repositories are destroyed too, since they’re now fully managed resources rather than assumed prerequisites. A terraform apply afterward rebuilds the entire stack from nothing, exactly as the first run did.

What Changed, Concretely

Part 3 required, per state: opening the task definition console, filling in two containers’ worth of configuration by hand, creating a service, manually wiring a load balancer, and repeating every field for each additional state over plain HTTP, with no persistent infrastructure ownership. This post reduces that entire workflow to one variable and one command, for any number of states, fully encrypted end-to-end, with every underlying resource clusters, repositories, log groups, certificates, load balancers, and services owned and reproducible by Terraform alone.

Key Takeaways

Only reference genuinely persistent, pre-existing infrastructure as data sources; anything your project creates and might reasonably destroy belongs as a managed resource, or a rebuild will fail because prerequisites are missing.
target_type = “ip” is mandatory for Fargate target groups, and it’s the key structural difference from EC2-based load balancing. ECS manages IP registration automatically, with no manual target group attachment resource required.
TLS wildcards match exactly one label; a certificate for *.kloudgov.online does not cover *.ecs.kloudgov.online; a second subdomain level needs its own certificate and validation chain.
Creating CloudWatch log groups explicitly, rather than relying on ECS to auto-create them, keeps the task execution role’s permissions narrower; it only needs to write to logs, not create new log groups.
Environment variables that differentiate tenants (bucket name, table name, state name) can be read directly from existing module outputs, with no manual copying of values between a Terraform apply and a console screen.
Scaling from one state to three, here, took a one-line variable change; the same payoff automation delivered throughout this entire project, regardless of which compute platform sits underneath it.

This closes out both the EC2/Ansible and ECS/Fargate paths for KloudGov, fully automated end to end, fully encrypted, and fully self-contained on each. Two different architectures, the same discipline applied to both: build it manually first, understand every piece, then automate it completely, and now a genuine, hands-on basis for knowing which one actually fits a given problem, rather than a guess.

{{ reviewsTotal }}{{ options.labels.singularReviewCountLabel }}
{{ reviewsTotal }}{{ options.labels.pluralReviewCountLabel }}
{{ options.labels.noReviewsLabel }}
{{ options.labels.newReviewButton }}
{{ userData.canReview.message }}

Related Posts

EKS-KloudGov-Cluster
AWS EKS Cluster Setup: Provisioning Kubernetes with eksctl and Terraform (Part 1)
This new series adds a third architecture: Kubernetes, via Amazon EKS (Elastic Kubernetes Service), fronted...
Docker-and-ECR
ECS Task Definitions and Fargate Services: Deploying KloudGov (Part 3)
creating the task definition through the console, launching the service, and watching it come up...
Docker-and-ECR
Terraform for ECS: Provisioning S3, DynamoDB, and Fargate Cluster (Part 2)
The interesting part of this post isn't creating new resources from scratch; it's adapting the...