Skip to content

feat(ecs-fargate-service): long-running container module + example stack - #8

Merged
kyleswiger merged 2 commits into
mainfrom
feat/ecs-fargate-service
Sep 24, 2026
Merged

kyleswiger merged 2 commits into
mainfrom
feat/ecs-fargate-service

Conversation

@kyleswiger

Copy link
Copy Markdown
Owner

Summary

  • New terraform-modules/ecs-fargate-service: cluster, task definition, service (Spot-capable, circuit breaker, ECS Exec), ALB + target group, ACM cert + Route 53 alias when a domain is given, ECR repo triad from lambda-container, ci_policy_statements output for github-oidc-role.
  • examples/ecs-fargate-stack in the numbered-comment style.
  • docs/methodology.md: "Long-running containers" — why ECS (App Runner closed to new customers 2026-04-30; Lambda can't carry WebSockets), the no-NAT public-subnet posture, ≈$27–35/mo floor.

First consumer: kyleswiger/beam-testbed (terraform/main.tf). Plan output attached in a comment once run.

Test plan

  • terraform fmt -check -recursive
  • terraform validate on module and example (aws provider 6.63 under >= 5.0)
  • terraform plan from beam-testbed against this branch
  • apply + first ECS deploy

🤖 Generated with Claude Code

One HTTP container on ECS Fargate behind an ALB with ACM TLS and a Route 53
alias. Defaults to the account's default VPC public subnets with a public IP
(no NAT gateway, no VPC endpoints). Terraform owns the shape; CI registers
task-definition revisions (service ignores task_definition drift). Exposes
ci_policy_statements for github-oidc-role, same pattern as preview-substrate.
App Runner is closed to new AWS customers since 2026-04-30.

@kyleswiger kyleswiger left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Gemini Automated Code Review

Reviewed automatically by Gemini PR Reviewer

📌 Executive Summary

This pull request introduces a highly robust, production-ready, and cost-optimized ECS Fargate module (ecs-fargate-service) alongside a complete example stack (ecs-fargate-stack). The design beautifully implements the "Terraform owns the infrastructure shape, CI/CD owns the application image" paradigm, preventing state conflicts during routine deployments. The default network posture is exceptionally well-thought-out, offering a low-cost entry point (~$27–35/mo) for running persistent VMs (like Elixir/BEAM or JVM) without the heavy overhead of NAT Gateways.

Overall, the code quality is outstanding, featuring excellent documentation, clear variable descriptions, and strict adherence to modern Terraform patterns. The risk assessment is Low, with only

@kyleswiger

Copy link
Copy Markdown
Owner Author

terraform plan from kyleswiger/beam-testbed against 0b32887

  # module.app.data.aws_iam_policy_document.execution_secrets[0] will be read during apply
  # (config refers to values not yet known)
  # module.app.aws_acm_certificate.this[0]
  # module.app.aws_acm_certificate_validation.this[0]
  # module.app.aws_cloudwatch_log_group.this
  # module.app.aws_ecr_lifecycle_policy.this[0]
  # module.app.aws_ecr_repository.this[0]
  # module.app.aws_ecs_cluster.this
  # module.app.aws_ecs_service.this
  # module.app.aws_ecs_task_definition.this
  # module.app.aws_iam_role.execution
  # module.app.aws_iam_role.task
  # module.app.aws_iam_role_policy.execution_secrets[0]
  # module.app.aws_iam_role_policy.task[0]
  # module.app.aws_iam_role_policy_attachment.execution_managed
  # module.app.aws_lb.this
  # module.app.aws_lb_listener.http
  # module.app.aws_lb_listener.https[0]
  # module.app.aws_lb_target_group.this
  # module.app.aws_route53_record.alias["A"]
  # module.app.aws_route53_record.alias["AAAA"]
  # module.app.aws_route53_record.cert_validation["beam.kswiger.dev"]
  # module.app.aws_security_group.alb
  # module.app.aws_security_group.task
  # module.ci_role.aws_iam_role.github_actions
  # module.ci_role.aws_iam_role_policy.ci[0]
Plan: 24 to add, 0 to change, 0 to destroy.

Comment thread examples/README.md Resolved
Resolve custom-domain validation, task-definition ownership, and example documentation review findings.

Co-Authored-By: Warp <agent@warp.dev>

@kyleswiger kyleswiger left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Gemini Automated Code Review

Reviewed automatically by Gemini PR Reviewer

what about KMS decryption?

statement {
  sid       = "DecryptSecrets"
  actions   = ["kms:Decrypt"]
  resources = ["*"]
  condition {
    test     = "StringEquals"
    variable = "kms:ViaService"
    values = [
      "ssm.${local.region}.amazonaws.com",
      "secretsmanager.${local.region}.amazonaws.com",
    ]
  }
}

This is also very good and secure (using kms:ViaService to restrict decryption).

  • Security Group Rules:
    The ALB security group allows ingress on 80 and 443 from `0.0.0

Comment on lines +289 to +296
wait_for_steady_state = false

capacity_provider_strategy {
capacity_provider = var.capacity_provider
weight = 1
}

network_configuration {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: aws_ecs_cluster.this never gets its Fargate capacity providers associated, so this capacity_provider_strategy will fail terraform apply.

aws_ecs_cluster.this is created with only a containerInsights setting — there's no aws_ecs_cluster_capacity_providers resource anywhere in this module (and the AWS provider ≥5.0 pinned in versions.tf removed the old capacity_providers/default_capacity_provider_strategy arguments from aws_ecs_cluster itself).

Per the ECS CreateCluster API docs: "A capacity provider must be associated with a cluster before it can be included as part of the default capacity provider strategy of the cluster or used in a capacity provider strategy when calling the CreateService or RunTask actions." Fargate/Fargate Spot only auto-associate for clusters created via the AWS Console — not via the API/Terraform.

Since this module always creates its own cluster and var.capacity_provider defaults to FARGATE (with FARGATE_SPOT as the only alternative, as used in examples/ecs-fargate-stack), every default terraform apply of this module will fail creating aws_ecs_service.this with an error that the capacity provider isn't associated with the cluster.

Suggested fix — add near the cluster resource:

resource "aws_ecs_cluster_capacity_providers" "this" {
  cluster_name       = aws_ecs_cluster.this.name
  capacity_providers = ["FARGATE", "FARGATE_SPOT"]
}

and add aws_ecs_cluster_capacity_providers.this to aws_ecs_service.this's depends_on (alongside the existing listener dependencies).

Comment on lines +103 to +122
},
],
var.create_ecr_repository ? [
{
sid = "EcrPush"
effect = "Allow"
actions = [
"ecr:BatchCheckLayerAvailability",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer",
"ecr:InitiateLayerUpload",
"ecr:UploadLayerPart",
"ecr:CompleteLayerUpload",
"ecr:PutImage",
]
resources = [aws_ecr_repository.this[0].arn]
},
] : [],
[
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: ci_policy_statements grants no ECR push permission when create_ecr_repository = false, despite advertising "ECR push" unconditionally.

The EcrPush statement (InitiateLayerUpload, UploadLayerPart, CompleteLayerUpload, PutImage, etc.) is wrapped in var.create_ecr_repository ? [...] : []. The only unconditional ECR statement is EcrAuth (ecr:GetAuthorizationToken, main.tf output line 99). "Bring your own existing ECR repo" is a first-class, documented mode of this module (variables.tf image_repository_url, README input table, and exercised directly by tests/offline/domain_pairing.tftest.hcl's publishes_task_template_for_ci run) — not an edge case.

So in that mode, a CI role built from this output (as wired in examples/ecs-fargate-stack/main.tf straight into github-oidc-role) can docker login but every docker push fails with AccessDenied, with nothing in the output's own description (line 95, "ECR push") or the module README's Outputs section flagging the gap.

Since an external repo's ARN is derivable from var.image_repository_url (<acct>.dkr.ecr.<region>.amazonaws.com/<name> → arn:aws:ecr:<region>:<acct>:repository/<name>), or could be taken as an optional image_repository_arn input, this looks fixable rather than an unavoidable limitation of the "bring your own repo" mode.

@kyleswiger
kyleswiger merged commit abe308e into main Sep 24, 2026
8 checks passed
@kyleswiger
kyleswiger deleted the feat/ecs-fargate-service branch September 24, 2026 14:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants