Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions infra/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Orbit infrastructure

The main Terraform state owns the Render production resources and the staging services, Cloudflare DNS and Turnstile, and AWS SSM parameters. `production.tf` imports the existing API and declares the production database, web service, and landing site. `staging.tf` declares the staging API, web service, and landing site. `configuration.tf` owns the service environment groups and their AWS SSM inputs. `cloudflare.tf` owns the zone, existing DNS records, widget, and its secret parameters. `ses.tf` owns SES identities, new DNS records, event routing, and send credentials. `variables.tf` contains the cutover switches and image digests. `versions.tf` pins the providers and configures the encrypted S3 state with native locking. `staging-database/` is a separate Terraform root that owns only the staging Postgres, its connection environment group, and the link to the staging API.
The main Terraform state owns the Render production resources and the staging services, Cloudflare DNS and Turnstile, and AWS SSM parameters. `production.tf` imports the existing API and declares the production database, web service, and landing site. `staging.tf` declares the staging API, web service, and landing site. `configuration.tf` owns the service environment groups and their AWS SSM inputs. `cloudflare.tf` owns the zone, existing DNS records, widget, and its secret parameters. `ses.tf` owns SES identities, new DNS records, event routing, and send credentials. `variables.tf` contains the configuration inputs and image digests. `versions.tf` pins the providers and configures the encrypted S3 state with native locking. `staging-database/` is a separate Terraform root that owns only the staging Postgres, its connection environment group, and the link to the staging API.

`uploads.tf` creates a private, encrypted S3 uploads bucket and a scoped IAM user in each environment. S3 is the only upload, read, and erasure provider. Terraform stores each user's access key in SecureString parameters at `/orbit/<environment>/api/Storage__S3__AccessKeyId` and `/orbit/<environment>/api/Storage__S3__SecretAccessKey`, then passes them to the API environment group. The S3 upload URL expires after 10 minutes. The stable API read URL redirects to a private S3 read URL that expires after 10 minutes. The API checks that the owner still exists before issuing a read redirect and sends `Cache-Control: no-store`. The API allows 600 read redirects per object per minute, so image pages and viewers sharing an IP have separate budgets for each object. Account erasure drains the user's `{userId}/` prefix in the S3 uploads bucket before removing the user row. Startup requires `Storage__S3__Bucket`, `Storage__S3__Region`, `Storage__S3__PublicBaseUrl`, `Storage__S3__AccessKeyId`, and `Storage__S3__SecretAccessKey`. Storage failures leave the account scheduled for another deletion run. `Supabase__Url` and `Supabase__AnonKey` remain required by the Google authentication client; Supabase storage settings are unused.

Expand Down Expand Up @@ -102,7 +102,7 @@ Provider arguments were confirmed using the installed `cloudflare/cloudflare` 5.

## SSM parameters

Create each entry as a SecureString in AWS Systems Manager Parameter Store in `us-east-2` before planning. Production API connection strings remain the existing Supabase Npgsql strings while `api_database = "supabase"`. They are no longer read when `api_database = "render"`. The Render database URL is converted to Npgsql's key and value connection string format for both API connection strings. Staging uses its own Render database from the start.
Create each entry as a SecureString in AWS Systems Manager Parameter Store in `us-east-2` before planning. Both production API connection strings always come from `render_postgres.production`. Its connection URL, database user, and password are converted to Npgsql's key and value connection string format. Staging uses its own Render database.

| Service | SecureString parameter |
| --- | --- |
Expand All @@ -123,8 +123,6 @@ Create each entry as a SecureString in AWS Systems Manager Parameter Store in `u
| Production API | `/orbit/production/api/TEST_ACCOUNTS` |
| Production API | `/orbit/production/api/Vapid__PrivateKey` |
| Production API | `/orbit/production/api/Waitlist__SigningKey` |
| Production API while using Supabase | `/orbit/production/api/ConnectionStrings__DefaultConnection` |
| Production API while using Supabase | `/orbit/production/api/ConnectionStrings__SessionConnection` |
| Production web | `/orbit/production/web/SENTRY_DSN` |
| Staging API | `/orbit/staging/api/AI__ApiKey` |
| Staging API | `/orbit/staging/api/Encryption__Key` |
Expand Down Expand Up @@ -202,7 +200,7 @@ After apply, read the `cloudflare_name_servers` output. Run `bash infra/check-dn

## Plan and apply

Set `RENDER_API_KEY` and `CLOUDFLARE_API_TOKEN` in the shell and provide AWS credentials through the default credential chain. Make a local `infra/local.tfvars` containing the published production and staging image digests and any approved domain or database cutover settings. This file is ignored; `example.tfvars` shows the shape only. Copy the production Postgres IP allow list from the Render Postgres Networking screen into `production_postgres_ip_allow_list`. The production variable defaults to `[]`, which removes production external access when applied. The staging root uses `staging_postgres_ip_allow_list` only at creation and ignores later allow list changes; use the `operator-mac` command above for local staging access. Check both database resources in every plan before applying; neither database should be replaced, and an existing operator address must not be removed. The requested `database_name` and `database_user` values are creation inputs; Terraform ignores later changes to those fields because Render assigns the live database name with a suffix. Connection strings read the database name from the resource URL.
Set `RENDER_API_KEY` and `CLOUDFLARE_API_TOKEN` in the shell and provide AWS credentials through the default credential chain. Make a local `infra/local.tfvars` containing the published production and staging image digests and any approved domain settings and database network access settings. This file is ignored; `example.tfvars` shows the shape only. Copy the production Postgres IP allow list from the Render Postgres Networking screen into `production_postgres_ip_allow_list`. The production variable defaults to `[]`, which removes production external access when applied. The staging root uses `staging_postgres_ip_allow_list` only at creation and ignores later allow list changes; use the `operator-mac` command above for local staging access. Check both database resources in every plan before applying; neither database should be replaced, and an existing operator address must not be removed. The requested `database_name` and `database_user` values are creation inputs; Terraform ignores later changes to those fields because Render assigns the live database name with a suffix. Connection strings read the database name from the resource URL.

The `production_web_digest` and `staging_web_digest` variables seed the web images only when Terraform first creates each service. Terraform ignores later digest changes on both web services. Subsequent staging and production web deploys use the manual `release.yml` workflow in `orbit-ui-mobile` with its `environment` input.

Expand Down Expand Up @@ -238,7 +236,7 @@ terraform -chdir=infra apply local.tfplan

The initial plan must keep `srv-d6tc2isr85hc739bf75g`, its URL, and `api.useorbit.org` in place. Terraform ignores the imported API's own environment variables (`lifecycle.ignore_changes`), so the first apply only creates `orbit-production-api` and links it; the service keeps its existing variables, which override the group's identical values. Check the imported service's plan before applying: it must show no change to the service.

For the production database cutover, first confirm the production data is present in the Render database shown on the Render Postgres Connections screen. Set `api_database = "render"` in `infra/local.tfvars` and run a fresh plan. Confirm both `ConnectionStrings__DefaultConnection` and `ConnectionStrings__SessionConnection` in the production API environment group name `orbit_production_9g8l`, as derived from that Render connection URL. Confirm `render_postgres.production` is unchanged and neither web service nor the imported API service changes in place. Apply the plan only after those checks. Verify the linked environment group and production API health, then remove the production API service's duplicated direct variables in the Render service Environment screen, leaving `ORBIT_TERRAFORM_ENV_GROUP`. Confirm the service still uses the Render database after the direct variables are removed.
Remove the obsolete `api_database` assignment from ignored `infra/local.tfvars`. Both `ConnectionStrings__DefaultConnection` and `ConnectionStrings__SessionConnection` in `orbit-production-api` use the managed Render database. A production plan for the selector removal must show no resource changes, including no environment group, service, or database updates. Only after that proof, delete `/orbit/production/api/ConnectionStrings__DefaultConnection` and `/orbit/production/api/ConnectionStrings__SessionConnection` from AWS Systems Manager Parameter Store in `us-east-2` without reading their values. Confirm both parameter names are absent afterward.

Staging reads Stripe test-mode product and price IDs from SSM and uses staging return URLs. `Supabase__Url` points at an invalid host for the existing Supabase authentication client. Staging uploads use their own S3 bucket. The web image must exist at both selected digests before the first apply. Set production web and landing custom domains during cutover. DNS and certificate verification follow the domain changes.

Expand Down
9 changes: 1 addition & 8 deletions infra/configuration.tf
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,7 @@ locals {

production_render_npgsql = "Host=${local.production_db_host};Port=5432;Database=${local.production_db_name};Username=${render_postgres.production.database_user};Password=\"${replace(render_postgres.production.connection_info.password, "\"", "\"\"")}\""

production_api_database_values = var.api_database == "supabase" ? {
for key in local.database_keys : key => data.aws_ssm_parameter.production_api_database[key].value
} : {
production_api_database_values = {
for key in local.database_keys : key => local.production_render_npgsql
}

Expand All @@ -143,11 +141,6 @@ data "aws_ssm_parameter" "staging_api_billing" {
name = "/orbit/staging/api/${each.key}"
}

data "aws_ssm_parameter" "production_api_database" {
for_each = var.api_database == "supabase" ? local.database_keys : toset([])
name = "/orbit/production/api/${each.key}"
}

data "aws_ssm_parameter" "production_web_sentry" {
name = "/orbit/production/web/SENTRY_DSN"
}
Expand Down
11 changes: 0 additions & 11 deletions infra/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,6 @@ variable "render_owner_id" {
default = "tea-ctg9ljtumphs73dep1o0"
}

variable "api_database" {
description = "Production API database source during the data migration."
type = string
default = "supabase"

validation {
condition = contains(["supabase", "render"], var.api_database)
error_message = "api_database must be supabase or render."
}
}

variable "production_postgres_ip_allow_list" {
description = "Operator addresses allowed to connect to production Postgres. Copy the live Render list into local.tfvars before planning."
type = list(object({
Expand Down
Loading