# main.tf — generated by `autumn release init --target aws-app-runner`
#
# Provisions {{project_name}} on AWS App Runner: an ECR repository, a VPC
# (private subnets for RDS + the App Runner VPC connector, one public subnet
# + NAT gateway so the running app still has general internet egress — see
# the note on `network_configuration` below), RDS PostgreSQL, Secrets
# Manager entries for the database URL and signing secret, the App Runner
# service itself, and a minimal one-shot ECS Fargate task+cluster whose only
# job is reaching private RDS to run `autumn migrate` (App Runner has no
# release-phase hook of its own — see the migration task's own comment
# below). Closest AWS analog to Fly.io otherwise: auto-TLS, auto-scale, no
# ALB or a real (app-hosting) cluster to manage.
#
# 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 service starts from a public ECR Public Gallery placeholder image
# (`var.bootstrap_image`): App Runner must pull an image to create the
# service's first revision, and a brand-new private ECR repository has none
# yet. The real deploy walkthrough (see docs/guide/deployment.md) swaps in
# your own image via `aws apprunner update-service` — which also sets
# AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS to the service's own URL, only known
# after this apply creates it (App Runner assigns its subdomain at creation
# time; unlike Azure Container Apps' environment-level default domain,
# there's nothing to derive it from in advance). Terraform is told to ignore
# further `source_configuration` drift (see the `lifecycle` block below) so
# a later `terraform apply` doesn't revert a live deploy back to the
# placeholder — and because the bootstrap and real deploy use structurally
# different repository types (`ECR_PUBLIC` vs. `ECR`), the whole block, not
# just the image identifier, must be ignored.
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
}
# App Runner service names and Secrets Manager secret names are both
# 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 resource. 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 (ECR, RDS identifiers, App
# Runner, the VPC connector) disallow uppercase/underscores or cap the
# length well below what a Cargo package name allows — 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 — a leading digit would
# otherwise survive sanitization untouched).
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 — generous headroom under RDS's 63-character
# identifier ceiling and App Runner/VPC-connector's own limits, while
# leaving room for the longest suffix appended below ("-connector", 10
# characters) plus the 8-hex-char random_id suffix some resources add.
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"
}
# ── Networking ──────────────────────────────────────────────────────────────
# RDS's DB subnet group requires 2+ AZs even for a single-AZ instance, and
# the App Runner VPC connector needs its own subnets. Setting
# `network_configuration.egress_configuration.egress_type = "VPC"` below
# routes ALL of the app's own outbound traffic through these subnets, not
# just traffic bound for RDS — without a NAT gateway, any outbound call the
# app itself makes (a third-party API, outbound mail, a webhook) would
# silently hang or fail. One public subnet + an internet gateway + a NAT
# gateway keeps the app's general internet egress working while RDS stays
# unreachable from the public internet.
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" {
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 4, 8)
availability_zone = local.azs[0]
map_public_ip_on_launch = true
tags = { Name = "${local.app_name_safe}-public" }
}
resource "aws_eip" "nat" {
domain = "vpc"
depends_on = [aws_internet_gateway.this]
tags = { Name = "${local.app_name_safe}-nat" }
}
resource "aws_nat_gateway" "this" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public.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" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
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_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" }
}
resource "aws_security_group" "vpc_connector" {
name_prefix = "${local.app_name_safe}-connector-"
description = "App Runner VPC connector egress"
vpc_id = aws_vpc.this.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 App Runner VPC connector only"
vpc_id = aws_vpc.this.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.vpc_connector.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
lifecycle {
create_before_destroy = true
}
}
resource "aws_apprunner_vpc_connector" "this" {
vpc_connector_name = "${local.app_name_safe}-connector"
subnets = aws_subnet.private[*].id
security_groups = [aws_security_group.vpc_connector.id]
}
# ── ECR ─────────────────────────────────────────────────────────────────────
resource "aws_ecr_repository" "this" {
name = local.app_name_safe
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
}
# ── IAM ─────────────────────────────────────────────────────────────────────
# App Runner's "access" role: lets the App Runner BUILD service pull from
# your PRIVATE ECR repository. Not used by the bootstrap image below (a
# PUBLIC ECR Gallery image needs no access role at all) — only once the real
# deploy walkthrough points source_configuration at your own repository.
data "aws_iam_policy_document" "apprunner_access_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["build.apprunner.amazonaws.com"]
}
}
}
resource "aws_iam_role" "apprunner_access" {
name = "${local.app_name_safe}-apprunner-access"
assume_role_policy = data.aws_iam_policy_document.apprunner_access_assume.json
}
resource "aws_iam_role_policy_attachment" "apprunner_access" {
role = aws_iam_role.apprunner_access.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSAppRunnerServicePolicyForECRAccess"
}
# App Runner's runtime "instance" role: what the RUNNING container assumes.
# Scoped to exactly the two secrets it needs to read at startup — never a
# wildcard — so a compromised container can't enumerate every secret in the
# account.
data "aws_iam_policy_document" "apprunner_instance_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["tasks.apprunner.amazonaws.com"]
}
}
}
resource "aws_iam_role" "apprunner_instance" {
name = "${local.app_name_safe}-apprunner-instance"
assume_role_policy = data.aws_iam_policy_document.apprunner_instance_assume.json
}
data "aws_iam_policy_document" "apprunner_instance_secrets" {
statement {
actions = ["secretsmanager:GetSecretValue"]
resources = [
aws_secretsmanager_secret.database_url.arn,
aws_secretsmanager_secret.signing_secret.arn,
]
}
}
resource "aws_iam_role_policy" "apprunner_instance_secrets" {
name = "secrets-access"
role = aws_iam_role.apprunner_instance.id
policy = data.aws_iam_policy_document.apprunner_instance_secrets.json
}
# App Runner has no release-phase hook of its own (unlike ECS's one-shot
# migration task or Azure's migration job), and RDS is private with no
# public entry point — so migrations need SOME compute inside this VPC that
# can reach it. These two roles back a one-shot ECS Fargate task (below)
# that exists ONLY to run `autumn migrate`; it deliberately reuses the same
# private subnets and VPC-connector security group App Runner's own traffic
# already uses (see aws_ecs_task_definition.migrate) rather than
# provisioning a second, parallel path into RDS. ECS's execution/task role
# split doesn't overlap with App Runner's own access/instance roles above —
# different trust policies (ecs-tasks.amazonaws.com vs.
# build/tasks.apprunner.amazonaws.com) mean the roles can't be shared.
data "aws_iam_policy_document" "ecs_tasks_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["ecs-tasks.amazonaws.com"]
}
}
}
resource "aws_iam_role" "migrate_execution" {
name = "${local.app_name_safe}-migrate-exec"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
resource "aws_iam_role_policy_attachment" "migrate_execution_managed" {
role = aws_iam_role.migrate_execution.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
}
data "aws_iam_policy_document" "migrate_execution_secrets" {
statement {
actions = ["secretsmanager:GetSecretValue"]
resources = [aws_secretsmanager_secret.database_url.arn]
}
}
resource "aws_iam_role_policy" "migrate_execution_secrets" {
name = "secrets-access"
role = aws_iam_role.migrate_execution.id
policy = data.aws_iam_policy_document.migrate_execution_secrets.json
}
# Empty by default (least privilege) — the migration task itself makes no
# AWS API calls, it only talks to RDS via the connection string injected by
# the execution role's secrets access above.
resource "aws_iam_role" "migrate_task" {
name = "${local.app_name_safe}-migrate-task"
assume_role_policy = data.aws_iam_policy_document.ecs_tasks_assume.json
}
# ── Secrets Manager ─────────────────────────────────────────────────────────
# AUTUMN_DATABASE__PRIMARY_URL — mirrors the same env var name used by the
# fly/docker-compose/azure-container-apps targets; only the binding
# mechanism (a Secrets Manager ARN referenced from App Runner's
# runtime_environment_secrets) differs. 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
}
# ── One-shot migration task ─────────────────────────────────────────────────
# A minimal ECS Fargate cluster + task definition whose only job is running
# `autumn migrate` inside the VPC, before each cutover — see the IAM role
# comment above for why this exists. The deploy walkthrough
# (docs/guide/deployment.md) registers a fresh revision with the real image
# and runs it via `aws ecs run-task --network-configuration` using the
# private_subnet_ids/vpc_connector_security_group_id outputs, so the task's
# ENI carries the same security group RDS's own ingress rule already trusts
# — no second security group or RDS rule to keep in sync.
resource "aws_cloudwatch_log_group" "migrate" {
name = "/ecs/${local.app_name_safe}-migrate"
retention_in_days = 30
}
resource "aws_ecs_cluster" "migrate" {
name = "${local.app_name_safe}-migrate"
}
resource "aws_ecs_task_definition" "migrate" {
family = "${local.app_name_safe}-migrate"
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
# Deliberately fixed and independent of var.instance_cpu/instance_memory:
# those are App Runner's own instance-sizing format ("1 vCPU"/"2 GB"), not
# the numeric Fargate task-definition units this resource requires, and a
# one-shot migration has no reason to scale with the running service's
# size anyway — the smallest Fargate size is enough to run `autumn migrate`.
cpu = "256"
memory = "512"
execution_role_arn = aws_iam_role.migrate_execution.arn
task_role_arn = aws_iam_role.migrate_task.arn
container_definitions = jsonencode([
{
name = "migrate"
image = var.bootstrap_image
essential = true
command = ["sh", "-lc", "autumn migrate"]
secrets = [
{ name = "AUTUMN_DATABASE__PRIMARY_URL", valueFrom = aws_secretsmanager_secret.database_url.arn },
]
logConfiguration = {
logDriver = "awslogs"
options = {
"awslogs-group" = aws_cloudwatch_log_group.migrate.name
"awslogs-region" = var.region
"awslogs-stream-prefix" = "migrate"
}
}
}
])
lifecycle {
# The deploy walkthrough registers a new revision with the real image
# ahead of every cutover (mirroring aws-ecs-main.tf's identical
# "migrate" task); without this, a later `terraform apply` would revert
# that revision back to the bootstrap placeholder.
ignore_changes = [container_definitions]
}
}
# ── App Runner ───────────────────────────────────────────────────────────────
resource "aws_apprunner_auto_scaling_configuration_version" "this" {
auto_scaling_configuration_name = "${local.app_name_safe}-asc"
min_size = var.min_size
max_size = var.max_size
max_concurrency = 100
}
resource "aws_apprunner_service" "this" {
service_name = "${local.app_name_safe}-${random_id.suffix.hex}"
source_configuration {
# Terraform only ever sets the bootstrap PUBLIC placeholder here — see
# the header comment. Real deploys land via `aws apprunner
# update-service` (docs/guide/deployment.md), which the `lifecycle`
# block below stops a later `terraform apply` from reverting.
image_repository {
image_identifier = var.bootstrap_image
image_repository_type = "ECR_PUBLIC"
image_configuration {
# var.bootstrap_image (nginx) listens on its own default port 80,
# not 3000 — declaring "3000" here while the bootstrap container
# only serves 80 would fail App Runner's own health check (below)
# on the very first apply, and `aws_apprunner_service` blocks
# `terraform apply` until the service reaches a stable state, so a
# failing bootstrap health check hangs the apply rather than just
# leaving a transient warning. Real deploys land via `aws
# apprunner update-service` (docs/guide/deployment.md), which
# passes the app's real port (3000) and health check path
# (/ready) explicitly in the same call — App Runner's health
# check configuration is a mutable, service-level setting, not
# tied to source_configuration, so that follow-up call is enough
# to restore it; Terraform never needs to re-apply it.
port = "80"
runtime_environment_variables = {
AUTUMN_PROFILE = "prod"
}
# The public bootstrap image only serves a health check and must
# never receive production secrets. The deploy walkthrough supplies
# them when it replaces this image with the trusted application.
}
}
auto_deployments_enabled = false
}
instance_configuration {
cpu = var.instance_cpu
memory = var.instance_memory
# Do not give the public bootstrap image the secret-reading runtime
# role. The real-image cutover attaches it in the same update-service
# operation that supplies RuntimeEnvironmentSecrets. The `lifecycle`
# block below ignores drift on this field only — cpu and memory stay
# under Terraform's control.
}
network_configuration {
egress_configuration {
egress_type = "VPC"
vpc_connector_arn = aws_apprunner_vpc_connector.this.arn
}
}
auto_scaling_configuration_arn = aws_apprunner_auto_scaling_configuration_version.this.arn
# "/" (nginx's own default welcome page, always 200), not "/ready" — see
# the image_configuration.port comment above; the real cutover call in
# docs/guide/deployment.md sets "/ready" once the actual app (which
# serves it) is live. /ready goes to 503 when a drain starts. App Runner
# also replaces an instance that stays unhealthy. The old alias path gave
# the same result.
health_check_configuration {
protocol = "HTTP"
path = "/"
}
depends_on = [
aws_iam_role_policy_attachment.apprunner_access,
]
lifecycle {
# CI/the manual walkthrough manages the real image (and, on the same
# call, the trusted-hosts env var — see the header comment) after
# bootstrap; without this, `terraform apply` would revert every live
# deploy back to `var.bootstrap_image` with an empty trusted-hosts list.
# The bootstrap (ECR_PUBLIC, no access role) and a real deploy (ECR,
# with an access role) are structurally different repository types, so
# the whole block — not just the image identifier — must be ignored.
#
# health_check_configuration is ignored for the same reason: the
# cutover call also switches the health check from the bootstrap's "/"
# (nginx's default response) to the real app's "/ready" (see the
# health_check_configuration comment above). Without ignoring it here
# too, a later `terraform apply` would see that as drift from this
# resource's own declared "/" and revert it — breaking the real app's
# health check the moment anyone re-applies.
#
# instance_configuration: ignore only instance_role_arn, not the whole
# block. The cutover call sets that field outside Terraform (see the
# instance_configuration comment above). Cutover never touches cpu or
# memory, so keep them out of this list — `terraform apply` must still
# resize the service after an operator changes var.instance_cpu or
# var.instance_memory.
ignore_changes = [source_configuration, instance_configuration[0].instance_role_arn, health_check_configuration]
}
}