> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# AWS deployment

> Provision the Causeloop product stack on AWS with the deploy/aws Terraform: every variable, every resource, the apply/migrate/bootstrap sequence, and teardown cautions.

`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](/deployment/environments) for how AWS fits alongside the local and Azure targets, and [Migrations](/deployment/migrations) for what the one-off migration task actually runs.

<Note>
  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.
</Note>

## 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](#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](/deployment/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](#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

```mermaid theme={null}
flowchart TB
    subgraph Public subnets
        ALB[ALB :443/:80]
    end
    subgraph Private subnets
        FE[frontend service :3000]
        API[api service :8000<br/>service discovery: api.causeloop-ENV.internal]
        PW[product-worker]
        PRW[provisioner-worker]
        RDS[(RDS PostgreSQL 16)]
        REDIS[(ElastiCache Redis, TLS + auth token)]
    end
    R53[Route 53 alias record] --> ALB
    ALB --> FE
    FE -->|"http://api.<namespace>:8000"| API
    API --> RDS
    API --> REDIS
    PW --> RDS
    PW --> REDIS
    PRW -->|owner DSN, DDL| RDS
    API -->|encrypted email outbox| RDS
    PW -.SES send.-> SES[Amazon SES]
    API -->|encrypt/decrypt| KMS[(KMS key)]
    API -->|objects| S3[(S3 bucket, versioned)]
```

`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`)

| Variable                                    | Default           | Meaning                                                                                                                                                                                                      |
| ------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `aws_region`                                | `eu-west-2`       | Region for every resource; also injected into containers as `AWS_DEFAULT_REGION`.                                                                                                                            |
| `environment`                               | `production`      | Tags resources, sets `local.name` (`causeloop-<environment>`), and toggles Multi-AZ/failover behavior on RDS, ElastiCache, and log retention.                                                                |
| `vpc_id`                                    | —                 | VPC containing the public ALB and private application/data subnets.                                                                                                                                          |
| `public_subnet_ids`                         | —                 | At least two public subnets for the internet-facing ALB.                                                                                                                                                     |
| `private_subnet_ids`                        | —                 | At least two private subnets with NAT/VPC endpoints for ECS, RDS, and Redis.                                                                                                                                 |
| `certificate_arn`                           | —                 | ACM certificate ARN for the public frontend hostname; attached to the HTTPS listener.                                                                                                                        |
| `public_hostname`                           | —                 | Public Route 53 hostname covered by `certificate_arn` (validated as a lowercase DNS name, no scheme, no trailing dot), e.g. `app.example.com`.                                                               |
| `route53_zone_id`                           | —                 | Route 53 public hosted-zone ID that owns `public_hostname`.                                                                                                                                                  |
| `email_sender_address`                      | —                 | SES sender address for invitations (validated as an email address); must be verified, with production access granted, before `activate_services = true`.                                                     |
| `initial_admin_email`                       | —                 | Email for the first idempotently bootstrapped onboarding administrator (validated as an email address).                                                                                                      |
| `initial_admin_name`                        | —                 | Display name for that administrator (1–200 characters after trimming).                                                                                                                                       |
| `initial_admin_password_secret_arn`         | —                 | ARN of the pre-created Secrets Manager secret holding the initial administrator's plaintext password (validated as a `secretsmanager:...:secret:` ARN).                                                      |
| `initial_admin_password_secret_kms_key_arn` | `null`            | Optional customer-managed KMS key ARN if that external secret isn't encrypted with the default Secrets Manager key.                                                                                          |
| `frontend_image`                            | —                 | Immutable frontend container image URI including digest or release tag.                                                                                                                                      |
| `backend_image`                             | —                 | Immutable backend container image URI including digest or release tag.                                                                                                                                       |
| `database_name`                             | `causeloop`       | RDS database name.                                                                                                                                                                                           |
| `database_owner_username`                   | `causeloop_owner` | Owner/migration role username; distinct from the runtime `app_rw` role.                                                                                                                                      |
| `database_owner_password`                   | — (sensitive)     | Owner role password. Supply via `TF_VAR_database_owner_password`.                                                                                                                                            |
| `app_database_password`                     | — (sensitive)     | Runtime `app_rw` role password. Supply via `TF_VAR_app_database_password`.                                                                                                                                   |
| `redis_auth_token`                          | — (sensitive)     | ElastiCache Redis AUTH token. Supply via `TF_VAR_redis_auth_token`.                                                                                                                                          |
| `db_instance_class`                         | `db.t4g.micro`    | RDS instance class.                                                                                                                                                                                          |
| `db_allocated_storage_gb`                   | `30`              | Initial RDS storage; `max_allocated_storage` autoscales to `max(4x this, 100)` GB.                                                                                                                           |
| `backup_retention_days`                     | `14`              | RDS automated backup retention and the ElastiCache snapshot retention limit.                                                                                                                                 |
| `object_retention_days`                     | `365`             | S3 lifecycle expiration for current object versions.                                                                                                                                                         |
| `deletion_protection`                       | `true`            | RDS deletion protection flag.                                                                                                                                                                                |
| `activate_services`                         | `false`           | Gates whether ECS services actually run tasks (see [Apply procedure](#apply-procedure)) — leave `false` until migration and admin-bootstrap both succeed.                                                    |
| `frontend_cpu` / `frontend_memory`          | `512` / `1024`    | Fargate CPU units / MiB for the frontend task.                                                                                                                                                               |
| `backend_cpu` / `backend_memory`            | `1024` / `2048`   | Fargate CPU units / MiB for the API, product-worker, and provisioner-worker tasks (the migration and admin-bootstrap tasks are fixed at 512 CPU / 1024 MiB in `main.tf`, not controlled by these variables). |
| `alert_email`                               | `null`            | Optional email endpoint subscribed to the CloudWatch alarm SNS topic.                                                                                                                                        |

## What `main.tf` provisions

<AccordionGroup>
  <Accordion title="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 after `object_retention_days` (365 by default), expires noncurrent versions after 30 days, and aborts incomplete multipart uploads after 7 days.
  </Accordion>

  <Accordion title="Networking and security groups">
    * `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`.
  </Accordion>

  <Accordion title="Data services">
    * `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`.
  </Accordion>

  <Accordion title="Secrets Manager">
    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.
  </Accordion>

  <Accordion title="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).
  </Accordion>

  <Accordion title="IAM: five distinct execution-role boundaries">
    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_provisioner` — `app_database_url`, `owner_database_url`.
    * `execution_migration` — `owner_database_url`, `app_database_password`.
    * `execution_admin_bootstrap` — `app_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.
  </Accordion>

  <Accordion title="ECS cluster, service discovery, and CloudWatch logs">
    * `aws_ecs_cluster.main` with Container Insights enabled.
    * `aws_service_discovery_private_dns_namespace.main` + `aws_service_discovery_service.api` (see [Topology](#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.
  </Accordion>

  <Accordion title="ALB, HTTPS, and DNS">
    * `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](/api-reference/integration/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`.
  </Accordion>

  <Accordion title="ECS task definitions and services">
    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](/architecture/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.
    * `migration` — `command = ["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_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 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.
  </Accordion>

  <Accordion title="Alarms">
    * `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.
  </Accordion>
</AccordionGroup>

## `terraform.tfvars.example` walkthrough

Copy `deploy/aws/terraform.tfvars.example` to `terraform.tfvars` (never committed — see `.gitignore`) and fill in account-specific values:

```hcl theme={null}
aws_region              = "eu-west-2"
environment             = "production"
vpc_id                  = "vpc-..."
public_subnet_ids       = ["subnet-public-a", "subnet-public-b"]
private_subnet_ids      = ["subnet-private-a", "subnet-private-b"]
certificate_arn         = "arn:aws:acm:eu-west-2:123456789012:certificate/..."
public_hostname         = "app.example.com"
route53_zone_id         = "Z..."
email_sender_address    = "no-reply@example.com"
initial_admin_email     = "admin@example.com"
initial_admin_name      = "Initial CauseLoop Admin"
initial_admin_password_secret_arn = "arn:aws:secretsmanager:eu-west-2:123456789012:secret:causeloop-initial-admin-..."
frontend_image          = "123456789012.dkr.ecr.eu-west-2.amazonaws.com/causeloop-frontend:release"
backend_image           = "123456789012.dkr.ecr.eu-west-2.amazonaws.com/causeloop-backend:release"
activate_services       = false
```

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:

```bash theme={null}
export TF_VAR_database_owner_password="..."
export TF_VAR_app_database_password="..."
export TF_VAR_redis_auth_token="..."
```

`alert_email` is optional (`platform@example.com` in the example); omit it if you don't want an SNS email subscription created.

## Apply procedure

<Steps>
  <Step title="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.
  </Step>

  <Step title="Initialize and apply with services deactivated">
    ```bash theme={null}
    cd deploy/aws
    terraform init
    terraform apply -var="activate_services=false"
    ```

    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.
  </Step>

  <Step title="Run the migration task">
    Export the values `run-migration.sh` needs from Terraform outputs, then run it:

    ```bash theme={null}
    export AWS_REGION=eu-west-2
    export ECS_CLUSTER="$(terraform output -raw ecs_cluster_name)"
    export MIGRATION_TASK_DEFINITION="$(terraform output -raw migration_task_definition_arn)"
    export PRIVATE_SUBNET_IDS="$(terraform output -json private_subnet_ids | tr -d '[]\"')"
    export SERVICE_SECURITY_GROUP_ID="$(terraform output -raw service_security_group_id)"
    ./run-migration.sh
    ```

    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.
  </Step>

  <Step title="Run the administrator-bootstrap task">
    Same pattern, with the admin-bootstrap task definition:

    ```bash theme={null}
    export ADMIN_BOOTSTRAP_TASK_DEFINITION="$(terraform output -raw admin_bootstrap_task_definition_arn)"
    ./run-admin-bootstrap.sh
    ```

    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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Activate services">
    Only after both one-off tasks have exited `0` and SES is out of the sandbox:

    ```bash theme={null}
    terraform apply -var="activate_services=true"
    ```

    This flips `desired_count` to 2 (frontend, api) / 1 (each worker) and creates the per-service `service_not_running` CloudWatch alarms.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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`:

    ```bash theme={null}
    python3 scripts/smoke_product.py \
      --frontend-url "$(terraform -chdir=deploy/aws output -raw frontend_url)"
    ```

    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.
  </Step>
</Steps>

<Warning>
  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.
</Warning>

## `outputs.tf`

| Output                                | Value                                                                                                     |
| ------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `frontend_url`                        | The public `https://<public_hostname>` URL — pass to `scripts/smoke_product.py --frontend-url`.           |
| `ecs_cluster_name`                    | `aws_ecs_cluster.main.name` — needed by both `run-*.sh` scripts as `ECS_CLUSTER`.                         |
| `migration_task_definition_arn`       | ARN for `run-migration.sh`'s `MIGRATION_TASK_DEFINITION`.                                                 |
| `admin_bootstrap_task_definition_arn` | ARN for `run-admin-bootstrap.sh`'s `ADMIN_BOOTSTRAP_TASK_DEFINITION`.                                     |
| `private_subnet_ids`                  | Echoes `var.private_subnet_ids` — feeds `PRIVATE_SUBNET_IDS` for both run scripts' `awsvpcConfiguration`. |
| `service_security_group_id`           | `aws_security_group.services.id` — feeds `SERVICE_SECURITY_GROUP_ID` for both run scripts.                |
| `object_bucket`                       | The S3 bucket name/id for the objects bucket.                                                             |
| `kms_key_arn`                         | The application KMS key ARN.                                                                              |
| `ses_sender_identity_arn`             | The SES email identity ARN.                                                                               |

## 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.

## Related

* [Cloud environments](/deployment/environments)
* [Azure deployment](/deployment/azure)
* [Migrations](/deployment/migrations)
* [Workers and jobs](/architecture/workers-and-jobs)
