Skip to main content
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.
This page documents 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 running terraform 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.0 and 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.tfvars or a local state file — both are already excluded by deploy/aws/.gitignore.
  • Immutable frontend and backend images already pushed to a registry (typically ECR) before the first apply — frontend_image and backend_image are image URIs, not build instructions. The images are the same ones documented in Cloud environments: ../frontend/Dockerfile and this repo’s root Dockerfile.
  • 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, and redis_auth_token are all sensitive variables with no default; pass them as TF_VAR_database_owner_password, TF_VAR_app_database_password, and TF_VAR_redis_auth_token from 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

  • 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 after object_retention_days (365 by default), expires noncurrent versions after 30 days, and aborts incomplete multipart uploads after 7 days.
  • aws_security_group.alb — allows inbound 80/443 from 0.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 from services.
  • aws_security_group.redis — allows inbound 6379 only from services.
  • aws_db_subnet_group.main and aws_elasticache_subnet_group.main bind RDS/ElastiCache to private_subnet_ids.
  • aws_db_instance.main — PostgreSQL 16, storage_encrypted = true with the application KMS key, publicly_accessible = false, Multi-AZ when environment = "production", gp3 storage that autoscales, deletion protection per var.deletion_protection, a final snapshot on destroy, Performance Insights enabled, and a sun:03:00–04:00 maintenance window after a 02:00–03:00 backup window.
  • aws_elasticache_replication_group.main — Redis with at_rest_encryption_enabled and transit_encryption_enabled both true, an AUTH token, two nodes with automatic failover and Multi-AZ when environment = "production" (one node otherwise), and snapshot retention matching backup_retention_days.
Four KMS-encrypted secrets, each with a generated name prefix (<name>/<purpose>-) so re-applies don’t collide with a deleted secret still in its recovery window:
  • app_database_url — the full postgresql://app_rw:... DSN with sslmode=require, built in locals.app_database_url.
  • owner_database_url — the full owner-role DSN, built in locals.owner_database_url.
  • app_database_password — the bare app_rw password (consumed only by the migration task, to create/update the role).
  • redis_url — the full rediss://:<token>@...:6379/0 connection string.
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).
Every task family gets its own ECS execution role (used only to pull the image and resolve secrets at container start) with the AWS-managed 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_provisionerapp_database_url, owner_database_url.
  • execution_migrationowner_database_url, app_database_password.
  • execution_admin_bootstrapapp_database_url and var.initial_admin_password_secret_arn (plus that secret’s KMS key if initial_admin_password_secret_kms_key_arn is set) — deliberately not the owner DSN, Redis, S3, the application KMS key beyond decrypt, or SES.
Separately, three task roles (used by application code at runtime, not just at container start):
  • runtime (API, product-worker) — S3 object read/write/list/abort-multipart on the objects bucket, KMS encrypt/decrypt/generate-data-key/describe, and ses:SendEmail scoped to the SES identity ARN with a ses:FromAddress condition pinned to email_sender_address.
  • frontend — no inline policy at all; the frontend task role has no data or secret access.
  • provisioner_task — only s3:ListBucket on the objects bucket (used for a readiness head_bucket-style check, never a write).
The migration and admin-bootstrap task definitions set no task_role_arn — they run with execution-role permissions only and have no application-level task role.
  • aws_ecs_cluster.main with Container Insights enabled.
  • aws_service_discovery_private_dns_namespace.main + aws_service_discovery_service.api (see Topology).
  • One aws_cloudwatch_log_group per component (frontend, api, product-worker, provisioner-worker, migration, admin-bootstrap) under /ecs/<name>/<component>, retained 30 days in production / 7 days otherwise.
  • aws_lb.frontend — internet-facing, in the public subnets, behind aws_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 any 200-399 status.
  • aws_lb_listener.http — port 80, redirects to 443 with HTTP_301.
  • aws_lb_listener.https — port 443, certificate_arn, TLS policy ELBSecurityPolicy-TLS13-1-2-2021-06, forwards to the frontend target group.
  • aws_route53_record.frontend — an alias A record for public_hostname pointing at the ALB, in route53_zone_id.
Six task definitions, all Fargate/awsvpc:
  • frontend — image var.frontend_image, NODE_ENV=production, PORT=3000, CAUSELOOP_API_URL=http://api.<namespace>:8000.
  • api — image var.backend_image, local.api_environment (common + email env), the two app_secrets (APP_DATABASE_URL, REDIS_URL), and a container health check that issues a Python urllib.request call to http://127.0.0.1:8000/health/ready (no curl dependency 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 both APP_DATABASE_URL and MIGRATIONS_DATABASE_URL secrets (the owner DSN) — the only long-running task with schema-DDL credentials.
  • migrationcommand = ["python", "scripts/migrate_product.py"], receiving MIGRATIONS_DATABASE_URL (owner DSN) and APP_RW_PASSWORD (so the migration can create/update the app_rw role itself).
  • admin_bootstrapcommand = ["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 only APP_DATABASE_URL and the bootstrap password secret.
Four 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.
  • aws_sns_topic.alerts (SNS-managed KMS encryption), with an optional email subscription when alert_email is 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 when RunningTaskCount drops below 1, treat_missing_data = "breaching". This for_each set is empty until activate_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:
The example file leaves 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

1

Build and push immutable images

Push tagged or digest-pinned frontend and backend images to your registry (ECR) before the first apply. frontend_image/backend_image reference these directly — Terraform does not build them.
2

Initialize and apply with services deactivated

This creates every resource except running ECS tasks for the long-running services (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.
3

Run the migration task

Export the values run-migration.sh needs from Terraform outputs, then run it:
The script runs the task with 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.
4

Run the administrator-bootstrap task

Same pattern, with the admin-bootstrap task definition:
This runs 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.
5

Verify the SES sender

Confirm the SES identity-verification email for 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.
6

Activate services

Only after both one-off tasks have exited 0 and SES is out of the sandbox:
This flips desired_count to 2 (frontend, api) / 1 (each worker) and creates the per-service service_not_running CloudWatch alarms.
7

Rotate the initial administrator credential

Sign in as the initial administrator, then rotate the external bootstrap secret and deliberately rerun 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.
8

Verify the deployment

Run this from the repo root (scripts/smoke_product.py lives there, not under deploy/aws), using -chdir to read the Terraform output from deploy/aws:
The smoke check reaches API health through the public frontend proxy, so the API itself stays private. Also manually confirm /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.
Of the database DSNs, the API and administrator-bootstrap tasks receive only APP_DATABASE_URL (the API additionally receives REDIS_URL). Only the migration task and the provisioner worker ever receive MIGRATIONS_DATABASE_URL (the owner DSN) — this is a deliberate blast-radius boundary, not an oversight, so do not add the owner secret to any other task definition.

outputs.tf

Teardown cautions

  • aws_db_instance.main has deletion_protection = var.deletion_protection (default true) and skip_final_snapshot = false with a named final_snapshot_identifier — a plain terraform destroy will not silently drop the database; you must first set deletion_protection = false and apply, and destroy will still take a final RDS snapshot.
  • aws_s3_bucket.objects has force_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.application has 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 = true stops user-facing services immediately; prefer setting activate_services = false and applying first if you want a graceful drain before a full teardown.
  • There is no environment-scoped destroy guard beyond deletion_protection and force_destroy — running terraform destroy against the wrong workspace/state is not otherwise prevented. Always confirm environment and the target state file before destroying.