deploy/aws is a validated Terraform stack (Terraform >= 1.8.0, the hashicorp/aws provider pinned to ~> 6.0 in deploy/aws/versions.tf) that provisions the exact same containers and environment contract as the local product stack (infra/docker-compose.product.yml): a public HTTPS frontend, a private FastAPI API, two differently privileged workers, one-off migration and administrator-bootstrap tasks, encrypted RDS PostgreSQL and TLS ElastiCache Redis, a private KMS-encrypted S3 bucket, and Amazon SES email delivery. See Cloud environments for how AWS fits alongside the local and Azure targets, and Migrations for what the one-off migration task actually runs.
deploy/aws/main.tf, variables.tf, outputs.tf, terraform.tfvars.example, and the two run-*.sh helper scripts as committed. If a claim here and docs/CLOUD_DEPLOYMENT.md ever disagree, this page follows the Terraform.Prerequisites
Before runningterraform apply, have these ready:
- An AWS account with permission to create VPC-attached resources, RDS, ElastiCache, S3, KMS, Secrets Manager, ECS/Fargate, an internet-facing ALB, Route 53 records, SNS, and CloudWatch alarms.
- Terraform
>= 1.8.0and the AWS CLI, both authenticated against the target account/region. - An existing VPC with at least two public subnets (for the ALB) and two private subnets. The private subnets need NAT or the VPC endpoints for ECR, CloudWatch, S3, KMS, and Secrets Manager — the API, workers, RDS, and Redis all run there with no direct internet route.
- A Route 53 public hosted zone that owns the hostname you intend to serve the frontend from, and an ACM certificate for that hostname in the same region as the stack.
- An SES sender address you control. The stack creates the SES email identity but cannot complete verification or request production access for you — see Apply procedure step 5.
- A remote encrypted Terraform backend with locking (for example S3 + DynamoDB, or Terraform Cloud). Never commit
terraform.tfvarsor a local state file — both are already excluded bydeploy/aws/.gitignore. - Immutable frontend and backend images already pushed to a registry (typically ECR) before the first apply —
frontend_imageandbackend_imageare image URIs, not build instructions. The images are the same ones documented in Cloud environments:../frontend/Dockerfileand this repo’s rootDockerfile. - A dedicated Secrets Manager secret created outside this Terraform state whose entire plaintext value is the initial administrator password (at least eight characters, not a JSON object). Pass its ARN as
initial_admin_password_secret_arn. Keep and rotate this secret — it is the infrastructure-managed break-glass reset path described in Apply procedure step 7. - Database and Redis passwords supplied through the environment, not the tfvars file —
database_owner_password,app_database_password, andredis_auth_tokenare allsensitivevariables with no default; pass them asTF_VAR_database_owner_password,TF_VAR_app_database_password, andTF_VAR_redis_auth_tokenfrom your CI secret store.
Topology
aws_service_discovery_private_dns_namespace creates a private DNS namespace (causeloop-<environment>.internal); the frontend task resolves the API at http://api.<namespace>:8000 via aws_service_discovery_service.api, a MULTIVALUE-routed A record — there is no internal load balancer in front of the API.
Variables (variables.tf)
What main.tf provisions
Encryption and object storage
Encryption and object storage
aws_kms_key.application/aws_kms_alias— a single customer-managed KMS key (30-day deletion window, automatic rotation enabled) used for RDS storage encryption, all Secrets Manager secrets, and application-level envelope encryption of credentials (CAUSELOOP_SECRET_PROVIDER=aws-kms,AWS_KMS_KEY_ID).aws_s3_bucket.objects— a versioned, KMS-encrypted, fully public-access-blocked bucket (bucket_prefix,force_destroy = false) for raw tenant uploads. A lifecycle rule expires current versions afterobject_retention_days(365 by default), expires noncurrent versions after 30 days, and aborts incomplete multipart uploads after 7 days.
Networking and security groups
Networking and security groups
aws_security_group.alb— allows inbound 80/443 from0.0.0.0/0, all egress.aws_security_group.services— allows inbound 3000 only from the ALB security group, and self-referential inbound 8000 (API-to-API / frontend-to-API service-discovery traffic), all egress. Every ECS task (frontend, API, both workers) uses this one security group.aws_security_group.database— allows inbound 5432 only fromservices.aws_security_group.redis— allows inbound 6379 only fromservices.aws_db_subnet_group.mainandaws_elasticache_subnet_group.mainbind RDS/ElastiCache toprivate_subnet_ids.
Data services
Data services
aws_db_instance.main— PostgreSQL 16,storage_encrypted = truewith the application KMS key,publicly_accessible = false, Multi-AZ whenenvironment = "production",gp3storage that autoscales, deletion protection pervar.deletion_protection, a final snapshot on destroy, Performance Insights enabled, and asun:03:00–04:00maintenance window after a02:00–03:00backup window.aws_elasticache_replication_group.main— Redis withat_rest_encryption_enabledandtransit_encryption_enabledboth true, an AUTH token, two nodes with automatic failover and Multi-AZ whenenvironment = "production"(one node otherwise), and snapshot retention matchingbackup_retention_days.
Secrets Manager
Secrets Manager
<name>/<purpose>-) so re-applies don’t collide with a deleted secret still in its recovery window:app_database_url— the fullpostgresql://app_rw:...DSN withsslmode=require, built inlocals.app_database_url.owner_database_url— the full owner-role DSN, built inlocals.owner_database_url.app_database_password— the bareapp_rwpassword (consumed only by the migration task, to create/update the role).redis_url— the fullrediss://:<token>@...:6379/0connection string.
Amazon SES
Amazon SES
aws_sesv2_email_identity.sender creates the SES email identity for email_sender_address. Terraform cannot complete inbox verification or request SES production access — do that manually before activate_services = true (SES sandbox accounts cannot email arbitrary recipients, only verified ones).IAM: five distinct execution-role boundaries
IAM: five distinct execution-role boundaries
AmazonECSTaskExecutionRolePolicy attached, plus, where secrets are needed, a scoped inline policy limiting exactly which Secrets Manager secrets and the KMS key that role may read:execution(API, product-worker) —app_database_url,redis_url.execution_frontend— no inline secrets policy; the frontend never resolves an application secret.execution_provisioner—app_database_url,owner_database_url.execution_migration—owner_database_url,app_database_password.execution_admin_bootstrap—app_database_urlandvar.initial_admin_password_secret_arn(plus that secret’s KMS key ifinitial_admin_password_secret_kms_key_arnis set) — deliberately not the owner DSN, Redis, S3, the application KMS key beyond decrypt, or SES.
runtime(API, product-worker) — S3 object read/write/list/abort-multipart on the objects bucket, KMS encrypt/decrypt/generate-data-key/describe, andses:SendEmailscoped to the SES identity ARN with ases:FromAddresscondition pinned toemail_sender_address.frontend— no inline policy at all; the frontend task role has no data or secret access.provisioner_task— onlys3:ListBucketon the objects bucket (used for a readinesshead_bucket-style check, never a write).
task_role_arn — they run with execution-role permissions only and have no application-level task role.ECS cluster, service discovery, and CloudWatch logs
ECS cluster, service discovery, and CloudWatch logs
aws_ecs_cluster.mainwith Container Insights enabled.aws_service_discovery_private_dns_namespace.main+aws_service_discovery_service.api(see Topology).- One
aws_cloudwatch_log_groupper component (frontend,api,product-worker,provisioner-worker,migration,admin-bootstrap) under/ecs/<name>/<component>, retained 30 days in production / 7 days otherwise.
ALB, HTTPS, and DNS
ALB, HTTPS, and DNS
aws_lb.frontend— internet-facing, in the public subnets, behindaws_security_group.alb.aws_lb_target_group.frontend— IP-target-type, port 3000, health check on/api/backend/health/ready(the frontend’s Next.js proxy route, matching the local product stack’s own path — see Proxy and sessions) accepting any200-399status.aws_lb_listener.http— port 80, redirects to 443 withHTTP_301.aws_lb_listener.https— port 443,certificate_arn, TLS policyELBSecurityPolicy-TLS13-1-2-2021-06, forwards to the frontend target group.aws_route53_record.frontend— an alias A record forpublic_hostnamepointing at the ALB, inroute53_zone_id.
ECS task definitions and services
ECS task definitions and services
awsvpc:frontend— imagevar.frontend_image,NODE_ENV=production,PORT=3000,CAUSELOOP_API_URL=http://api.<namespace>:8000.api— imagevar.backend_image,local.api_environment(common + email env), the twoapp_secrets(APP_DATABASE_URL,REDIS_URL), and a container health check that issues a Pythonurllib.requestcall tohttp://127.0.0.1:8000/health/ready(nocurldependency in the image).product_worker— same image,command = ["python", "scripts/run_pipeline_worker.py"],CAUSELOOP_WORKER_COMPONENT=product-worker,CAUSELOOP_WORKER_JOB_TYPES=materialize_insights,send_email,train_tenant_model— see Workers and jobs for what those job types do.provisioner_worker— same command,CAUSELOOP_WORKER_JOB_TYPES=provision_tenant, and bothAPP_DATABASE_URLandMIGRATIONS_DATABASE_URLsecrets (the owner DSN) — the only long-running task with schema-DDL credentials.migration—command = ["python", "scripts/migrate_product.py"], receivingMIGRATIONS_DATABASE_URL(owner DSN) andAPP_RW_PASSWORD(so the migration can create/update theapp_rwrole itself).admin_bootstrap—command = ["python", "scripts/create_employee.py", "--email", var.initial_admin_email, "--name", var.initial_admin_name, "--role", "onboarding_admin", "--password-env", "CAUSELOOP_BOOTSTRAP_ADMIN_PASSWORD"], receiving onlyAPP_DATABASE_URLand the bootstrap password secret.
aws_ecs_service resources (frontend, api, product_worker, provisioner_worker — migration and admin-bootstrap are one-off tasks, not services) all set desired_count = var.activate_services ? N : 0 (2 for frontend/api, 1 for each worker) and enable deployment_circuit_breaker with automatic rollback.Alarms
Alarms
aws_sns_topic.alerts(SNS-managed KMS encryption), with an optional email subscription whenalert_emailis set.aws_cloudwatch_metric_alarm.alb_5xx— fires when the ALB reports more than 5 target 5xx responses over two 5-minute periods.aws_cloudwatch_metric_alarm.database_storage— fires when RDS free storage drops below 5 GiB.aws_cloudwatch_metric_alarm.service_not_running— one alarm per activated ECS service (frontend, api, product-worker, provisioner-worker), firing whenRunningTaskCountdrops below 1,treat_missing_data = "breaching". Thisfor_eachset is empty untilactivate_services = true, so the alarms only exist once services do.
terraform.tfvars.example walkthrough
Copy deploy/aws/terraform.tfvars.example to terraform.tfvars (never committed — see .gitignore) and fill in account-specific values:
database_owner_password, app_database_password, and redis_auth_token as literal placeholder strings (“supply-through-TF_VAR_...”) — do not put real passwords in the tfvars file itself. Export them as environment variables instead:
alert_email is optional (platform@example.com in the example); omit it if you don’t want an SNS email subscription created.
Apply procedure
Build and push immutable images
frontend_image/backend_image reference these directly — Terraform does not build them.Initialize and apply with services deactivated
desired_count = 0) — RDS, Redis, S3, KMS, Secrets Manager, IAM, the ALB, and the migration/admin-bootstrap task definitions all exist and are ready to use.Run the migration task
run-migration.sh needs from Terraform outputs, then run it:aws ecs run-task --launch-type FARGATE (no public IP), waits for it to stop with aws ecs wait tasks-stopped, and asserts the container’s exit code is 0 — it fails loudly (set -eu plus explicit test assertions) rather than leaving you to check the console.Run the administrator-bootstrap task
scripts/create_employee.py inside the container with the plaintext password taken from your pre-created Secrets Manager secret — never from Terraform state. It is idempotent: rerunning it (for example, to rotate the credential per step 7 below) updates the same employee row rather than creating a duplicate.Verify the SES sender
email_sender_address and request SES production access. Skipping this leaves the account in the SES sandbox, which can only send to addresses you’ve individually verified — invitations to real users will silently fail to reach anyone else.Activate services
0 and SES is out of the sandbox:desired_count to 2 (frontend, api) / 1 (each worker) and creates the per-service service_not_running CloudWatch alarms.Rotate the initial administrator credential
run-admin-bootstrap.sh once to invalidate the initial credential — this is the infrastructure-managed break-glass reset path; the product intentionally publishes no unused staff self-reset endpoint. Verify you can sign in with the new credential before considering this done.Verify the deployment
scripts/smoke_product.py lives there, not under deploy/aws), using -chdir to read the Terraform output from deploy/aws:/health/ready, one delivered invitation, HTTPS end to end, an upload/replay round trip, worker retry behavior, tenant isolation, backup status, and that the CloudWatch alarms are wired to a real recipient.outputs.tf
Teardown cautions
aws_db_instance.mainhasdeletion_protection = var.deletion_protection(defaulttrue) andskip_final_snapshot = falsewith a namedfinal_snapshot_identifier— a plainterraform destroywill not silently drop the database; you must first setdeletion_protection = falseand apply, and destroy will still take a final RDS snapshot.aws_s3_bucket.objectshasforce_destroy = false— Terraform will refuse to destroy the bucket while it still contains (versioned) objects. Empty it deliberately first if teardown is truly intended.aws_kms_key.applicationhas a 30-day deletion window — destroying it does not immediately revoke access; anything encrypted with it (RDS, Secrets Manager, S3, application credential envelopes) is unrecoverable once the window elapses and the key is gone.- Destroying while
activate_services = truestops user-facing services immediately; prefer settingactivate_services = falseand applying first if you want a graceful drain before a full teardown. - There is no environment-scoped destroy guard beyond
deletion_protectionandforce_destroy— runningterraform destroyagainst the wrong workspace/state is not otherwise prevented. Always confirmenvironmentand the target state file before destroying.