skills-ws
Version:
87 agent skills for AI coding assistants — marketing, growth, web3, dev, design & operations. Built for OpenClaw, Claude Code, Cursor, and Codex.
474 lines (421 loc) • 17.6 kB
Markdown
## Contents
- 2. ECS Fargate with Auto-Scaling
- 2a. CodeDeploy blue/green resources
- 2b. Simpler alternative: ECS rolling deploy with circuit breaker
## 2. ECS Fargate with Auto-Scaling
```hcl
# modules/ecs/main.tf
variable "project" { type = string }
variable "environment" { type = string }
variable "vpc_id" { type = string }
variable "private_subnet_ids" { type = list(string) }
variable "public_subnet_ids" { type = list(string) }
variable "container_image" { type = string }
variable "container_port" { default = 3000 }
variable "cpu" { default = 512 }
variable "memory" { default = 1024 }
variable "desired_count" { default = 2 }
variable "min_count" { default = 2 }
variable "max_count" { default = 10 }
variable "health_check_path" { default = "/health" }
variable "secrets_arn" { type = string }
variable "certificate_arn" { type = string }
variable "admin_cidr" { type = string } # trusted CIDR for the blue/green test listener
resource "aws_ecs_cluster" "main" {
name = "${var.project}-${var.environment}"
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${var.project}-${var.environment}/app"
retention_in_days = 30
}
# Task execution role — pulls images, writes logs, reads secrets
resource "aws_iam_role" "task_execution" {
name = "${var.project}-${var.environment}-task-exec"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Action = "sts:AssumeRole", Effect = "Allow", Principal = { Service = "ecs-tasks.amazonaws.com" } }]
})
}
resource "aws_iam_role_policy_attachment" "task_execution" {
role = aws_iam_role.task_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
resource "aws_iam_role_policy" "task_execution_secrets" {
role = aws_iam_role.task_execution.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Effect = "Allow", Action = ["secretsmanager:GetSecretValue"], Resource = [var.secrets_arn] }]
})
}
# Task role — what YOUR CODE runs as. Least privilege.
resource "aws_iam_role" "task" {
name = "${var.project}-${var.environment}-task"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Action = "sts:AssumeRole", Effect = "Allow", Principal = { Service = "ecs-tasks.amazonaws.com" } }]
})
}
resource "aws_iam_role_policy" "task" {
role = aws_iam_role.task.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{ Effect = "Allow", Action = ["s3:GetObject","s3:PutObject"], Resource = ["arn:aws:s3:::${var.project}-${var.environment}-uploads/*"] },
# Permissions for the ADOT collector sidecar below: forward traces to
# X-Ray and pull centralized sampling rules.
{ Effect = "Allow",
Action = ["xray:PutTraceSegments","xray:PutTelemetryRecords","xray:GetSamplingRules","xray:GetSamplingTargets","xray:GetSamplingStatisticSummaries"],
Resource = ["*"] },
# Required for ECS Exec (enable_execute_command below). Without these four
# SSM Messages actions on the TASK role, `aws ecs execute-command` fails with
# "execute command failed because execute command was not enabled".
{ Effect = "Allow",
Action = ["ssmmessages:CreateControlChannel","ssmmessages:CreateDataChannel","ssmmessages:OpenControlChannel","ssmmessages:OpenDataChannel"],
Resource = ["*"] }
]
})
}
data "aws_region" "current" {}
resource "aws_ecs_task_definition" "app" {
family = "${var.project}-${var.environment}"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = var.cpu
memory = var.memory
execution_role_arn = aws_iam_role.task_execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([
{
name = "app"
image = var.container_image
portMappings = [{ containerPort = var.container_port, protocol = "tcp" }]
secrets = [
{ name = "DATABASE_URL", valueFrom = "${var.secrets_arn}:DATABASE_URL::" },
{ name = "REDIS_URL", valueFrom = "${var.secrets_arn}:REDIS_URL::" }
]
environment = [
{ name = "NODE_ENV", value = var.environment },
{ name = "PORT", value = tostring(var.container_port) }
]
logConfiguration = {
logDriver = "awslogs"
options = { "awslogs-group" = aws_cloudwatch_log_group.app.name, "awslogs-region" = data.aws_region.current.region, "awslogs-stream-prefix" = "app" }
}
healthCheck = {
command = ["CMD-SHELL", "curl -f http://localhost:${var.container_port}/health || exit 1"]
interval = 30, timeout = 5, retries = 3, startPeriod = 60
}
},
# Tracing sidecar: ADOT collector (OpenTelemetry), forwards traces to X-Ray.
# The X-Ray daemon and SDKs entered maintenance mode on February 25, 2026
# (security fixes only); AWS recommends OpenTelemetry instrumentation. If you
# must keep the daemon for an existing app, pin amazon/aws-xray-daemon:3.x,
# never :latest.
{
name = "aws-otel-collector", image = "public.ecr.aws/aws-observability/aws-otel-collector:latest"
cpu = 32, memory = 256, essential = false
command = ["--config=/etc/ecs/ecs-default-config.yaml"]
portMappings = [{ containerPort = 4317, protocol = "tcp" }, { containerPort = 4318, protocol = "tcp" }]
logConfiguration = { logDriver = "awslogs", options = { "awslogs-group" = aws_cloudwatch_log_group.app.name, "awslogs-region" = data.aws_region.current.region, "awslogs-stream-prefix" = "otel" } }
}
])
}
# Security groups
# Only CloudFront's origin-facing ranges may reach the ALB. Opening 80/443 to
# 0.0.0.0/0 would let clients hit the ALB DNS name directly and bypass the WAF
# and rate limits attached to CloudFront in section 4. For defense in depth,
# also verify a secret custom origin header at the ALB.
data "aws_ec2_managed_prefix_list" "cloudfront" {
name = "com.amazonaws.global.cloudfront.origin-facing"
}
resource "aws_security_group" "alb" {
name_prefix = "${var.project}-${var.environment}-alb-"
vpc_id = var.vpc_id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
prefix_list_ids = [data.aws_ec2_managed_prefix_list.cloudfront.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle { create_before_destroy = true }
}
resource "aws_security_group" "ecs" {
name_prefix = "${var.project}-${var.environment}-ecs-"
vpc_id = var.vpc_id
ingress {
from_port = var.container_port
to_port = var.container_port
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle { create_before_destroy = true }
}
# ALB
resource "aws_lb" "main" {
name = "${var.project}-${var.environment}"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = var.public_subnet_ids
enable_deletion_protection = var.environment == "production"
drop_invalid_header_fields = true
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.main.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.blue.arn
}
lifecycle { ignore_changes = [default_action] }
}
resource "aws_lb_listener" "http_redirect" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
# --- Two target groups for CodeDeploy blue/green ---
# CodeDeploy swaps the production listener between these two groups. Both must
# exist up front; the running service is attached to exactly one at a time and
# CodeDeploy shifts traffic to the other on each deploy.
resource "aws_lb_target_group" "blue" {
name_prefix = "blue-"
port = var.container_port
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip"
deregistration_delay = 30
health_check {
path = var.health_check_path
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 15
matcher = "200"
}
lifecycle { create_before_destroy = true }
}
resource "aws_lb_target_group" "green" {
name_prefix = "green-"
port = var.container_port
protocol = "HTTP"
vpc_id = var.vpc_id
target_type = "ip"
deregistration_delay = 30
health_check {
path = var.health_check_path
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 15
matcher = "200"
}
lifecycle { create_before_destroy = true }
}
# Test listener on :8443 — lets CodeDeploy validate the green stack before it
# receives production traffic. Reuse the prod cert or a separate test cert.
resource "aws_lb_listener" "test" {
load_balancer_arn = aws_lb.main.arn
port = 8443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = var.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.green.arn
}
lifecycle { ignore_changes = [default_action] }
}
# Allow the test-listener port through the ALB and into the tasks. The green
# task set on :8443 is not yet validated, so never expose it to 0.0.0.0/0:
# scope it to a trusted admin CIDR (or the CloudFront prefix list above).
resource "aws_security_group_rule" "alb_test_ingress" {
type = "ingress"
security_group_id = aws_security_group.alb.id
from_port = 8443
to_port = 8443
protocol = "tcp"
cidr_blocks = [var.admin_cidr] # e.g. your office/VPN CIDR
}
# ECS Service — CodeDeploy-controlled blue/green with auto-rollback.
# NOTE: deployment_controller = CODE_DEPLOY is INCOMPATIBLE with the ECS
# deployment_circuit_breaker / deployment_configuration blocks; rollback is
# configured on the CodeDeploy deployment group instead (see section 2a). If you
# prefer plain ECS rolling deploys, swap to the variant in section 2b — do NOT
# mix the two.
resource "aws_ecs_service" "app" {
name = "${var.project}-${var.environment}"
cluster = aws_ecs_cluster.main.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.desired_count
launch_type = "FARGATE"
enable_execute_command = true
deployment_controller { type = "CODE_DEPLOY" }
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.ecs.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.blue.arn
container_name = "app"
container_port = var.container_port
}
# CodeDeploy mutates task_definition and load_balancer on each deploy; ignore
# them so Terraform does not fight CodeDeploy.
lifecycle { ignore_changes = [task_definition, load_balancer] }
}
# Auto-scaling on CPU and request count
resource "aws_appautoscaling_target" "ecs" {
max_capacity = var.max_count
min_capacity = var.min_count
resource_id = "service/${aws_ecs_cluster.main.name}/${aws_ecs_service.app.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "${var.project}-${var.environment}-cpu"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification { predefined_metric_type = "ECSServiceAverageCPUUtilization" }
target_value = 65
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
resource "aws_appautoscaling_policy" "requests" {
name = "${var.project}-${var.environment}-requests"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.ecs.resource_id
scalable_dimension = aws_appautoscaling_target.ecs.scalable_dimension
service_namespace = aws_appautoscaling_target.ecs.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ALBRequestCountPerTarget"
# With CodeDeploy blue/green the live target group alternates blue<->green,
# so per-target request scaling is approximate right after a deploy. If you
# need exact request-based scaling under blue/green, prefer a CPU/memory
# target (above) or a custom CloudWatch metric on the ALB request count.
resource_label = "${aws_lb.main.arn_suffix}/${aws_lb_target_group.blue.arn_suffix}"
}
target_value = 1000
scale_in_cooldown = 300
scale_out_cooldown = 60
}
}
```
### 2a. CodeDeploy blue/green resources
These complete the blue/green deploy the service above declares. CodeDeploy needs an app, a deployment group bound to the ECS service + ALB listeners + both target groups, and an IAM role. The `AppSpec` and the GitHub Actions invocation are in section 5.
```hcl
# modules/ecs/codedeploy.tf
resource "aws_codedeploy_app" "app" {
name = "${var.project}-${var.environment}"
compute_platform = "ECS"
}
resource "aws_iam_role" "codedeploy" {
name = "${var.project}-${var.environment}-codedeploy"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{ Action = "sts:AssumeRole", Effect = "Allow", Principal = { Service = "codedeploy.amazonaws.com" } }]
})
}
resource "aws_iam_role_policy_attachment" "codedeploy" {
role = aws_iam_role.codedeploy.name
policy_arn = "arn:aws:iam::aws:policy/AWSCodeDeployRoleForECS"
}
resource "aws_codedeploy_deployment_group" "app" {
app_name = aws_codedeploy_app.app.name
deployment_group_name = "${var.project}-${var.environment}"
service_role_arn = aws_iam_role.codedeploy.arn
deployment_config_name = "CodeDeployDefault.ECSCanary10Percent5Minutes"
deployment_style {
deployment_type = "BLUE_GREEN"
deployment_option = "WITH_TRAFFIC_CONTROL"
}
blue_green_deployment_config {
# Spin up the green task set, run validation, then shift traffic.
deployment_ready_option { action_on_timeout = "CONTINUE_DEPLOYMENT" }
# Keep old (blue) task set for 15 min so you can roll back instantly.
terminate_blue_instances_on_deployment_success {
action = "TERMINATE"
termination_wait_time_in_minutes = 15
}
}
auto_rollback_configuration {
enabled = true
events = ["DEPLOYMENT_FAILURE", "DEPLOYMENT_STOP_ON_ALARM"]
}
ecs_service {
cluster_name = aws_ecs_cluster.main.name
service_name = aws_ecs_service.app.name
}
load_balancer_info {
target_group_pair_info {
prod_traffic_route { listener_arns = [aws_lb_listener.https.arn] }
test_traffic_route { listener_arns = [aws_lb_listener.test.arn] }
target_group { name = aws_lb_target_group.blue.name }
target_group { name = aws_lb_target_group.green.name }
}
}
}
output "ecs_cluster_name" { value = aws_ecs_cluster.main.name }
output "ecs_service_name" { value = aws_ecs_service.app.name }
output "codedeploy_app_name" { value = aws_codedeploy_app.app.name }
output "codedeploy_deployment_group" { value = aws_codedeploy_deployment_group.app.deployment_group_name }
output "alb_arn_suffix" { value = aws_lb.main.arn_suffix }
output "alb_dns_name" { value = aws_lb.main.dns_name }
output "ecs_security_group_id" { value = aws_security_group.ecs.id }
```
### 2b. Simpler alternative: ECS rolling deploy with circuit breaker
If you do NOT need blue/green (no per-deploy test traffic, faster rollouts are fine), drop section 2a, drop the test listener, and use the standard ECS rolling controller. Pick exactly one of 2a or 2b — `CODE_DEPLOY` and the circuit-breaker block are mutually exclusive.
```hcl
# Replacement for the aws_ecs_service.app body in section 2.
# deployment_controller defaults to ECS, so just omit it.
enable_execute_command = true
deployment_minimum_healthy_percent = 100
deployment_maximum_percent = 200
deployment_circuit_breaker {
enable = true
rollback = true
}
# ECS-native rolling deploys mutate the task definition, so do NOT ignore it:
lifecycle { ignore_changes = [] }
```
With 2b, the GitHub Actions "Deploy" step in section 5 (`aws ecs update-service --force-new-deployment`) is the correct deploy mechanism. With 2a, use the CodeDeploy step shown there instead.
---