# main.tf — generated by `autumn release init --target aws-ecs`
#
# Provisions {{project_name}} on AWS ECS Fargate: a VPC with public/private
# subnets across 2 AZs, an internet-facing ALB (HTTP->HTTPS redirect, ACM
# certificate via Route 53 DNS validation), an ECR repository, an ECS
# cluster + Fargate task definition + service (rolling deploys with a
# deployment circuit breaker so a bad revision auto-rolls-back), Application
# Auto Scaling on CPU/memory, RDS PostgreSQL in private subnets, Secrets
# Manager entries for the database URL and signing secret, and a one-shot
# ECS task definition that runs `autumn migrate`. An optional ElastiCache
# Redis replication group is gated behind `enable_redis_cache` (off by
# default) — see the callout on it below.
#
# Secrets (`signing_secret`, `database_admin_password`) have no default in
# variables.tf. Supply them via `TF_VAR_*` environment variables or your
# CI's secret store — never commit real values here or in terraform.tfvars.
# The database connection string is NOT a variable: it's derived below from
# the RDS instance this same apply creates, so one `terraform apply` is
# enough — no second, targeted apply to fill in a value that depends on a
# resource the first apply was about to create.
#
# The "app" and "migrate" ECS task definitions both start from a public
# placeholder image (`var.bootstrap_image`): Fargate must pull an image to
# register a task definition's first revision, and a brand-new ECR
# repository has none yet. CI (aws-deploy.yml) registers a new task
# definition revision with the real image on every deploy and points the
# service/run-task calls at it, and Terraform is told to ignore that drift
# (see the `lifecycle` blocks below) so a later `terraform apply` doesn't
# revert a live deploy back to the placeholder.
terraform {
required_version = ">= 1.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
provider "aws" {
region = var.region
}
# Secrets Manager secret names are soft-deleted for a retention window after
# deletion — a bare re-apply under the same app_name inside that window
# would otherwise collide with the just-deleted secret. This suffix keeps
# repeat applies stable while avoiding that collision.
resource "random_id" "suffix" {
byte_length = 4
}
data "aws_availability_zones" "available" {
state = "available"
}
locals {
azs = slice(data.aws_availability_zones.available.names, 0, 2)
# AWS resource names touched by this scaffold disallow uppercase/
# underscores or cap the length well below what a Cargo package name
# allows — the ALB and target group names are the tightest, at 32
# characters — so sanitize once here instead of re-deriving it per
# resource. Maps anything but lowercase alphanumerics/hyphens to a
# hyphen, collapses hyphen runs, trims a leading/trailing hyphen, and
# falls back to a fixed "app" prefix when that leaves nothing or leaves a
# value not starting with a letter (RDS instance identifiers must start
# with a letter).
app_name_hyphenated_raw = trim(
replace(lower(replace(var.app_name, "/[^a-zA-Z0-9-]/", "-")), "/-+/", "-"),
"-"
)
app_name_hyphenated = (
local.app_name_hyphenated_raw == "" ? "app" : (
can(regex("^[a-z]", local.app_name_hyphenated_raw)) ? local.app_name_hyphenated_raw : "app-${local.app_name_hyphenated_raw}"
)
)
# Capped at 20 characters — headroom under the ALB/target-group 32-
# character ceiling (the tightest limit this scaffold touches; the
# longest single-resource suffix appended below is "-alb", 4 characters)
# and comfortably under RDS's own 63-character identifier ceiling and
# IAM's more generous 64-character role-name limit.
app_name_safe = trim(substr(local.app_name_hyphenated, 0, 20), "-")
# Postgres database identifiers (the RDS `db_name` parameter) allow only
# letters, digits, and underscores, must start with a letter, and are
# capped at 63 bytes — unlike every other name here, which uses hyphens.
# A fresh RDS Postgres instance already reserves "postgres"/"rdsadmin" as
# system databases, plus "template0"/"template1" as Postgres's own
# cluster-wide templates; a Cargo package literally named one of those (or
# e.g. "rds-admin", which sanitizes to the same underscored name) would
# otherwise collide with them and fail `terraform apply`.
postgres_database_name_raw = substr(replace(local.app_name_hyphenated, "-", "_"), 0, 63)
postgres_database_name = contains(
["postgres", "rdsadmin", "template0", "template1"],
local.postgres_database_name_raw
) ? "${substr(local.postgres_database_name_raw, 0, 58)}_prod" : local.postgres_database_name_raw
postgres_admin_username = "autumnadmin"
container_environment = concat(
[
{ name = "AUTUMN_PROFILE", value = "prod" },
# Known in advance — unlike App Runner's own assigned subdomain, the
# ALB serves under the domain the operator supplied and validated via
# ACM, so there's no chicken-and-egg problem requiring a second apply
# or a post-create CLI call to fill this in.
{ name = "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS", value = var.domain_name },
],
var.enable_redis_cache ? [{ name = "AUTUMN_CACHE__BACKEND", value = "redis" }] : []
)
container_secrets = concat(
[
{ name = "AUTUMN_DATABASE__PRIMARY_URL", valueFrom = aws_secretsmanager_secret.database_url.arn },
{ name = "AUTUMN_SECURITY__SIGNING_SECRET", valueFrom = aws_secretsmanager_secret.signing_secret.arn },
],
var.enable_redis_cache ? [
{ name = "AUTUMN_CACHE__REDIS__URL", valueFrom = aws_secretsmanager_secret.redis_url[0].arn },
] : []
)
secrets_manager_arns = compact(concat(
[aws_secretsmanager_secret.database_url.arn, aws_secretsmanager_secret.signing_secret.arn],
var.enable_redis_cache ? [aws_secretsmanager_secret.redis_url[0].arn] : []
))
}
# ── Networking ──────────────────────────────────────────────────────────────
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${local.app_name_safe}-vpc" }
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = { Name = "${local.app_name_safe}-igw" }
}
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, 8 + count.index)
availability_zone = local.azs[count.index]
map_public_ip_on_launch = true
tags = { Name = "${local.app_name_safe}-public-${count.index}" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, count.index)
availability_zone = local.azs[count.index]
tags = { Name = "${local.app_name_safe}-private-${count.index}" }
}
resource "aws_eip" "nat" {
domain = "vpc"
depends_on = [aws_internet_gateway.this]
tags = { Name = "${local.app_name_safe}-nat" }
}
# A single NAT gateway (not one per AZ) — a scaffold cost/simplicity
# tradeoff: every private-subnet task shares one gateway's availability, so
# an AZ outage taking the NAT's own AZ down with it can interrupt egress for
# tasks in the OTHER AZ too. Add a second NAT gateway (one per AZ, each
# private route table pointed at the one in its own AZ) before a real
# multi-AZ production cutover.
resource "aws_nat_gateway" "this" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
depends_on = [aws_internet_gateway.this]
tags = { Name = "${local.app_name_safe}-nat" }
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id
}
tags = { Name = "${local.app_name_safe}-public" }
}
resource "aws_route_table_association" "public" {
count = 2
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.this.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this.id
}
tags = { Name = "${local.app_name_safe}-private" }
}
resource "aws_route_table_association" "private" {
count = 2
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
resource "aws_db_subnet_group" "this" {
name = "${local.app_name_safe}-db"
subnet_ids = aws_subnet.private[*].id
tags = { Name = "${local.app_name_safe}-db" }
}
# ── Security groups ─────────────────────────────────────────────────────────
resource "aws_security_group" "alb" {
name_prefix = "${local.app_name_safe}-alb-"
description = "Public HTTP/HTTPS ingress to the ALB"
vpc_id = aws_vpc.this.id
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 443
to_port = 443
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"]
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_security_group" "ecs_tasks" {
name_prefix = "${local.app_name_safe}-ecs-"
description = "Allow the ALB to reach the app container; tasks reach out for ECR/Secrets Manager/RDS via the NAT gateway"
vpc_id = aws_vpc.this.id
ingress {
from_port = 3000
to_port = 3000
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
}
}
resource "aws_security_group" "database" {
name_prefix = "${local.app_name_safe}-db-"
description = "Allow Postgres from the ECS tasks only"
vpc_id = aws_vpc.this.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.ecs_tasks.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" "redis" {
count = var.enable_redis_cache ? 1 : 0
name_prefix = "${local.app_name_safe}-redis-"
description = "Allow Redis from the ECS tasks only"
vpc_id = aws_vpc.this.id
ingress {
from_port = 6379
to_port = 6379
protocol = "tcp"
security_groups = [aws_security_group.ecs_tasks.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle {
create_before_destroy = true
}
}
# ── ACM certificate (DNS validation via Route 53) ───────────────────────────
resource "aws_acm_certificate" "this" {
domain_name = var.domain_name
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate.this.domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
value = dvo.resource_record_value
}
}
zone_id = var.route53_zone_id
name = each.value.name
type = each.value.type
records = [each.value.value]
ttl = 60
allow_overwrite = true
}
resource "aws_acm_certificate_validation" "this" {
certificate_arn = aws_acm_certificate.this.arn
validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}
resource "aws_route53_record" "app" {
zone_id = var.route53_zone_id
name = var.domain_name
type = "A"
alias {
name = aws_lb.this.dns_name
zone_id = aws_lb.this.zone_id
evaluate_target_health = true
}
}
# ── ALB ─────────────────────────────────────────────────────────────────────
resource "aws_lb" "this" {
name = "${local.app_name_safe}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = aws_subnet.public[*].id
}
resource "aws_lb_target_group" "this" {
name = "${local.app_name_safe}-tg"
port = 3000
protocol = "HTTP"
vpc_id = aws_vpc.this.id
target_type = "ip"
# The readiness probe. Autumn sets /ready to 503 when a drain starts, and
# the ALB then stops new traffic to the task. ECS also replaces a task
# that stays unhealthy, for example when the database is down.
health_check {
path = "/ready"
healthy_threshold = 2
unhealthy_threshold = 3
interval = 15
timeout = 5
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
default_action {
type = "redirect"
redirect {
port = "443"
protocol = "HTTPS"
status_code = "HTTP_301"
}
}
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.this.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
# Referencing the VALIDATION resource's certificate_arn (not
# aws_acm_certificate.this.arn directly) makes Terraform wait for DNS
# validation to actually complete before wiring the listener — attaching
# an unvalidated (still-PENDING_VALIDATION) certificate to a listener
# fails at apply time.
certificate_arn = aws_acm_certificate_validation.this.certificate_arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.this.arn
}
}
# ── ECR ─────────────────────────────────────────────────────────────────────
resource "aws_ecr_repository" "this" {
name = local.app_name_safe
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
# ── IAM ─────────────────────────────────────────────────────────────────────
data "aws_iam_policy_document" "ecs_tasks_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
# The EXECUTION role — what the ECS agent assumes to pull the image from
# ECR, ship logs to CloudWatch, and inject the `secrets` block below. The
# managed policy covers ECR/logs; secrets access is scoped to exactly the
# ARNs this app uses (never a wildcard) via the inline policy beneath it.
resource "aws_iam_role" "execution" {
name = "${local.app_name_safe}-execution"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
resource "aws_iam_role_policy_attachment" "execution_managed" {
role = aws_iam_role.execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
data "aws_iam_policy_document" "execution_secrets" {
statement {
actions = ["secretsmanager:GetSecretValue"]
resources = local.secrets_manager_arns
}
}
resource "aws_iam_role_policy" "execution_secrets" {
name = "secrets-access"
role = aws_iam_role.execution.id
policy = data.aws_iam_policy_document.execution_secrets.json
}
# The TASK role — what the RUNNING app container itself assumes for any AWS
# API calls it makes at runtime. Empty by default (least privilege): attach
# statements to a `aws_iam_role_policy` on this role if/when the app needs
# to call other AWS services.
resource "aws_iam_role" "task" {
name = "${local.app_name_safe}-task"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
# ── Secrets Manager ─────────────────────────────────────────────────────────
# AUTUMN_DATABASE__PRIMARY_URL — derived from the RDS instance this same
# apply creates (not a variable), so a single `terraform apply` provisions
# the database AND the secret that points at it.
resource "aws_secretsmanager_secret" "database_url" {
name = "${local.app_name_safe}/database-url-${random_id.suffix.hex}"
}
resource "aws_secretsmanager_secret_version" "database_url" {
secret_id = aws_secretsmanager_secret.database_url.id
secret_string = "postgres://${local.postgres_admin_username}:${urlencode(var.database_admin_password)}@${aws_db_instance.this.address}:5432/${local.postgres_database_name}?sslmode=require"
}
# AUTUMN_SECURITY__SIGNING_SECRET — required in production; see
# docs/guide/signing-secrets.md. Generate with `openssl rand -hex 32`.
resource "aws_secretsmanager_secret" "signing_secret" {
name = "${local.app_name_safe}/signing-secret-${random_id.suffix.hex}"
}
resource "aws_secretsmanager_secret_version" "signing_secret" {
secret_id = aws_secretsmanager_secret.signing_secret.id
secret_string = var.signing_secret
}
# ── RDS ─────────────────────────────────────────────────────────────────────
resource "aws_db_instance" "this" {
identifier = "${local.app_name_safe}-pg"
engine = "postgres"
engine_version = "16"
instance_class = var.db_instance_class
allocated_storage = 20
storage_encrypted = true
db_name = local.postgres_database_name
username = local.postgres_admin_username
password = var.database_admin_password
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.database.id]
publicly_accessible = false
# Scaffold default: lets `terraform destroy`/recreate cycles run without
# waiting on a final snapshot. Set to `false` (and set
# `final_snapshot_identifier`) before a real production cutover.
skip_final_snapshot = true
}
# ── Optional ElastiCache Redis ──────────────────────────────────────────────
# Gated behind `enable_redis_cache`, off by default so a plain deploy incurs
# no extra cost.
#
# This provisions the infrastructure and wires AUTUMN_CACHE__BACKEND /
# AUTUMN_CACHE__REDIS__URL into the ECS task, but that alone does NOT make
# the app use it: Autumn's cache subsystem has no built-in Redis
# implementation (unlike sessions/channels/jobs, which activate from config
# alone once compiled with the `redis` Cargo feature). Your application must
# ALSO depend on the separate `autumn-cache-redis` crate and register
# `.plugin(RedisCachePlugin::new())` in main.rs — without that, these env
# vars are parsed into config and never read by anything, the app silently
# keeps its default in-process cache, and you pay for an ElastiCache
# instance that does nothing. See the "Shared Cache" section of
# docs/guide/cloud-native.md for the exact three steps.
resource "aws_elasticache_subnet_group" "this" {
count = var.enable_redis_cache ? 1 : 0
name = "${local.app_name_safe}-redis"
subnet_ids = aws_subnet.private[*].id
}
# ElastiCache requires an auth_token (>= 16 chars) whenever
# transit_encryption_enabled is set — generated here rather than taken as a
# variable, unlike database_admin_password/signing_secret: nothing outside
# this Terraform state needs to know or reuse it, so there's no reason to
# make an operator supply and remember a third secret just to flip this flag
# on.
resource "random_password" "redis_auth_token" {
count = var.enable_redis_cache ? 1 : 0
length = 32
special = false
}
resource "aws_elasticache_replication_group" "this" {
count = var.enable_redis_cache ? 1 : 0
replication_group_id = "${local.app_name_safe}-redis"
description = "Redis for {{project_name}} (autumn-cache-redis)"
engine = "redis"
engine_version = "7.1"
node_type = "cache.t4g.micro"
num_cache_clusters = 1
subnet_group_name = aws_elasticache_subnet_group.this[0].name
security_group_ids = [aws_security_group.redis[0].id]
transit_encryption_enabled = true
at_rest_encryption_enabled = true
auth_token = random_password.redis_auth_token[0].result
}
resource "aws_secretsmanager_secret" "redis_url" {
count = var.enable_redis_cache ? 1 : 0
name = "${local.app_name_safe}/redis-url-${random_id.suffix.hex}"
}
resource "aws_secretsmanager_secret_version" "redis_url" {
count = var.enable_redis_cache ? 1 : 0
secret_id = aws_secretsmanager_secret.redis_url[0].id
secret_string = "rediss://:${urlencode(random_password.redis_auth_token[0].result)}@${aws_elasticache_replication_group.this[0].primary_endpoint_address}:6379"
}
# ── CloudWatch Logs ──────────────────────────────────────────────────────────
resource "aws_cloudwatch_log_group" "app" {
name = "/ecs/${local.app_name_safe}"
retention_in_days = 30
}
resource "aws_cloudwatch_log_group" "migrate" {
name = "/ecs/${local.app_name_safe}-migrate"
retention_in_days = 30
}
# ── ECS ─────────────────────────────────────────────────────────────────────
resource "aws_ecs_cluster" "this" {
name = local.app_name_safe
setting {
name = "containerInsights"
value = "enabled"
}
}
resource "aws_ecs_task_definition" "app" {
family = local.app_name_safe
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.cpu
memory = var.memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([
{
name = "app"
# Terraform only ever sets the bootstrap placeholder here — see the
# header comment. Real deploys register a new task definition
# revision with the real image (aws-deploy.yml / the manual
# walkthrough in docs/guide/deployment.md), which the `lifecycle`
# block below stops a later `terraform apply` from reverting.
image = var.bootstrap_image
essential = true
# var.bootstrap_image (nginx) doesn't listen on port 3000 or serve
# /ready out of the box — but unlike App Runner, the ALB target
# group's health check (aws_lb_target_group.this, fixed at port 3000
# + path /ready) is a PERMANENT Terraform-managed resource the real
# app must satisfy too; there's no separate "swap it back after
# cutover" step available here the way there is for App Runner's
# mutable, service-level health check. So the bootstrap revision must
# ALSO pass it: reconfigure the public nginx image, at startup, to
# listen on 3000 and return 200 for /ready and /, overriding its
# own entrypoint entirely so this runs regardless of the image's own
# default command.
entryPoint = ["sh", "-c"]
command = [
"echo 'server { listen 3000; location /ready { return 200; } location / { return 200; } }' > /etc/nginx/conf.d/default.conf && exec nginx -g 'daemon off;'"
]
portMappings = [
{ containerPort = 3000, protocol = "tcp" }
]
environment = local.container_environment
# A public bootstrap image must not receive production credentials.
# Deploys copy the secret references from the non-running migration
# task definition only when registering the trusted real app image.
secrets = []
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.app.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "app"
}
}
}
])
# KNOWN LIMITATION: because container_definitions is ignored wholesale
# (see below), a config-only change — flipping enable_redis_cache, or
# editing domain_name — never reaches AWS on its own. CI's task
# definition registration (aws-deploy.yml) clones whatever revision is
# CURRENTLY ACTIVE in AWS via `describe-task-definition`, not this
# resource's locally-declared state, so there is no `terraform apply`
# that pushes such a change out.
#
# ignore_changes can't be scoped to just the `image` key inside
# container_definitions — it's a single jsonencode()'d string attribute,
# and Terraform's ignore_changes only operates on whole attributes, never
# a path inside one. Ignoring the whole thing is deliberate, not an
# oversight: the alternative (removing this ignore_changes entirely)
# would make Terraform re-register a bootstrap-image revision as the
# family's latest on the very next unrelated `terraform apply` — CI's
# `describe-task-definition --task-definition "$APP_TASK_FAMILY"` fetches
# by FAMILY NAME, always the latest revision regardless of who registered
# it, so the NEXT deploy would clone that bootstrap revision (nginx,
# wrong entrypoint) instead of the real app's last known-good one — the
# exact class of bug this ignore_changes exists to prevent, just
# triggered from the other direction.
#
# To push a genuine config-only change out: `terraform taint
# aws_ecs_task_definition.app && terraform apply` forces a fresh
# revision reflecting the current .tf-declared container_definitions
# (including the bootstrap image — the live ECS SERVICE is unaffected,
# since it separately ignores task_definition drift), then re-run
# aws-deploy.yml (or the manual walkthrough) once more so CI clones that
# new revision — now carrying the config change — forward onto the real
# image.
lifecycle {
ignore_changes = [container_definitions]
}
}
resource "aws_ecs_service" "this" {
name = local.app_name_safe
cluster = aws_ecs_cluster.this.id
task_definition = aws_ecs_task_definition.app.arn
desired_count = var.desired_count
launch_type = "FARGATE"
network_configuration {
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.ecs_tasks.id]
assign_public_ip = false
}
load_balancer {
target_group_arn = aws_lb_target_group.this.arn
container_name = "app"
container_port = 3000
}
deployment_circuit_breaker {
enable = true
rollback = true
}
deployment_maximum_percent = 200
deployment_minimum_healthy_percent = 100
# The database_url/signing_secret/redis_url *version* resources (not just
# the secret containers referenced by ARN in local.container_secrets)
# must exist before tasks are scheduled: the task definition's `secrets`
# block only implies a dependency on the empty secret container, since
# that's the resource actually referenced by .arn — without this,
# Terraform is free to schedule tasks as soon as the (still-valueless)
# secret exists, racing the version write. Tasks then fail secret
# injection at startup, and the deployment circuit breaker above can
# permanently fail the first deployment before the versions appear.
# aws_iam_role_policy_attachment.execution_managed/aws_iam_role_policy.
# execution_secrets are also required here: the task definition's
# execution_role_arn only orders creation after the ROLE itself, not the
# policies attached to it, so without this the service could schedule
# tasks before the execution role can actually pull the image, ship logs,
# or inject secrets — failing during resource initialization and
# potentially tripping the circuit breaker above on the very first apply.
depends_on = [
aws_lb_listener.https,
aws_secretsmanager_secret_version.database_url,
aws_secretsmanager_secret_version.signing_secret,
aws_secretsmanager_secret_version.redis_url,
aws_iam_role_policy_attachment.execution_managed,
aws_iam_role_policy.execution_secrets,
]
lifecycle {
# CI registers new task definition revisions and repoints the service
# at them directly (aws-deploy.yml); without this, `terraform apply`
# would revert every live deploy back to the bootstrap revision above.
#
# desired_count is ignored for the same class of reason: Application
# Auto Scaling (below) changes it directly at runtime in response to
# load, and that's a live scaling decision, not drift — without
# ignoring it, a later `terraform apply` would treat the scaled value
# as having drifted from var.desired_count and forcibly reset it,
# either yanking capacity during a load spike or adding unwanted tasks
# right after a scale-in.
ignore_changes = [task_definition, desired_count]
}
}
# One-shot migration task definition — mirrors the docker-compose target's
# dedicated `migrate` service and Azure's one-shot Container Apps Job. CI
# (aws-deploy.yml) registers a fresh revision of this family with the new
# release image and runs it via `aws ecs run-task`, waiting for it to exit
# 0 *before* the app service is updated to the new image, so a
# schema-dependent release never serves traffic against the old schema (the
# generated production config sets `auto_migrate_in_production = false` —
# no running task migrates on its own). Nothing ever runs this
# automatically; only an explicit `run-task` call starts it.
resource "aws_ecs_task_definition" "migrate" {
family = "${local.app_name_safe}-migrate"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = var.cpu
memory = var.memory
execution_role_arn = aws_iam_role.execution.arn
task_role_arn = aws_iam_role.task.arn
container_definitions = jsonencode([
{
name = "migrate"
image = var.bootstrap_image
essential = true
command = ["sh", "-lc", "autumn migrate"]
# Use the full app secret list here. Do not use only the DB URL.
# CI and the manual walkthrough copy this list to the "app" task.
# See the "app" container's `secrets = []` comment above.
# A shorter list here removes secrets from the real app.
secrets = local.container_secrets
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.migrate.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "migrate"
}
}
}
])
lifecycle {
ignore_changes = [container_definitions]
}
}
# ── Application Auto Scaling ─────────────────────────────────────────────────
# Scale defaults: desired = var.desired_count (2), min = var.min_count (1),
# max = var.max_count (10). Target-tracking on CPU and memory independently;
# Application Auto Scaling scales on whichever policy asks for more capacity.
resource "aws_appautoscaling_target" "this" {
max_capacity = var.max_count
min_capacity = var.min_count
resource_id = "service/${aws_ecs_cluster.this.name}/${aws_ecs_service.this.name}"
scalable_dimension = "ecs:service:DesiredCount"
service_namespace = "ecs"
}
resource "aws_appautoscaling_policy" "cpu" {
name = "${local.app_name_safe}-cpu"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.this.resource_id
scalable_dimension = aws_appautoscaling_target.this.scalable_dimension
service_namespace = aws_appautoscaling_target.this.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageCPUUtilization"
}
target_value = 70
}
}
resource "aws_appautoscaling_policy" "memory" {
name = "${local.app_name_safe}-memory"
policy_type = "TargetTrackingScaling"
resource_id = aws_appautoscaling_target.this.resource_id
scalable_dimension = aws_appautoscaling_target.this.scalable_dimension
service_namespace = aws_appautoscaling_target.this.service_namespace
target_tracking_scaling_policy_configuration {
predefined_metric_specification {
predefined_metric_type = "ECSServiceAverageMemoryUtilization"
}
target_value = 75
}
}