Skip to content

First Deployment — Dev Environment

This is the complete, step-by-step guide for getting Construo running on AWS for the first time. Work through every step in order. Each step depends on the previous one.

Time required: approximately 3–4 hours, including AWS provisioning wait times.

Prerequisites before starting:

  • An AWS account with billing set up (root account is fine for now; you'll lock it down in step 2)
  • A Mac or Linux machine
  • The platform repo cloned locally and all tests passing locally
  • The domain construo.build registered (or whatever domain you're using)

Step 1 — Install local tools

Install every tool you'll use during this session. Verify each version before continuing.

# Homebrew (Mac)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

# AWS CLI v2
brew install awscli
aws --version
# Expected: aws-cli/2.x.x

# Terraform
brew install terraform
terraform version
# Expected: Terraform v1.7.x or higher

# Docker Desktop
# Download from https://www.docker.com/products/docker-desktop/
# After install:
docker --version
# Expected: Docker version 24.x or higher

# GitHub CLI
brew install gh
gh --version

Step 2 — Secure the AWS root account

Do this before creating any resources. The root account has unlimited power and must never be used for day-to-day work.

  1. Sign in to console.aws.amazon.com as root.
  2. Account name — top-right menu → Account → Account name: set to Construo Dev.
  3. Enable MFA on root:
    • Top-right → Security credentials → Multi-factor authentication → Assign MFA device
    • Choose Authenticator app, scan the QR code with 1Password or Authy
    • Enter two consecutive codes to confirm
    • Save the MFA seed in 1Password under AWS → Root MFA
  4. Create a billing alert:
    • Search for Billing → Budgets → Create budget
    • Type: Cost budget, Period: Monthly, Budgeted amount: £200
    • Alerts: 80% actual, 100% actual → your email
  5. Never use the root account again. Everything below uses an IAM admin user.

Step 3 — Create an IAM admin user

This is the account you'll use for all AWS work.

  1. IAM → Users → Create user
    • Username: gari-admin
    • Check Provide user access to the AWS Management Console
    • Custom password, untick "must change password"
  2. Permissions → Attach policies directly → AdministratorAccess
  3. Create user. Download the CSV with the credentials. Save to 1Password under AWS → IAM Admin.
  4. Sign out of root. Sign in as gari-admin.
  5. Enable MFA on this user too (same process as root).

Configure the AWS CLI:

aws configure
# AWS Access Key ID: [paste from CSV]
# AWS Secret Access Key: [paste from CSV]
# Default region: eu-west-2
# Default output format: json

# Verify it works:
aws sts get-caller-identity
# Expected: {"UserId": "...", "Account": "123456789012", "Arn": "arn:aws:iam::..."}

Note your 12-digit Account ID — you'll need it in step 7.


Step 4 — Create the Terraform state backend

Terraform needs an S3 bucket for state, a DynamoDB table to prevent concurrent applies, and a KMS key to encrypt the state. These must exist before Terraform can manage anything, so they're created by a one-off bootstrap script (the one sanctioned use of raw aws CLI for creating infrastructure).

Run it against the state account — confirm your identity first:

cd infra/terraform/bootstrap
aws sts get-caller-identity   # confirm the intended account
./bootstrap.sh --dry-run      # prints every command, changes nothing
./bootstrap.sh                # idempotent — safe to re-run

This creates the KMS CMK alias/construo-terraform-state, the versioned, SSE-KMS, public-access-blocked construo-terraform-state bucket, and the terraform-state-lock DynamoDB table. Full detail — including the account-per-env assume-role model — is in Terraform State & Accounts.


Step 5 — Configure Terraform for dev

5a — Fill in terraform.tfvars

Open infra/terraform/environments/dev/terraform.tfvars and replace the placeholder values:

# Get your account ID:
aws sts get-caller-identity --query Account --output text

# Generate the offline token secret:
python3 -c "import secrets; print(secrets.token_hex(32))"

Edit the file — it looks like this:

aws_account_id       = "REPLACE_WITH_YOUR_AWS_ACCOUNT_ID"   # e.g. "123456789012"
offline_token_secret = "REPLACE_WITH_GENERATED_SECRET"       # 64-char hex string
powersync_url        = ""                                     # leave empty for now

Save it. Never commit this file — it's in .gitignore.

5b — Initialise Terraform

cd infra/terraform/environments/dev
terraform init

Expected output ends with:

Terraform has been successfully initialized!


Step 6 — Apply Terraform (infrastructure provisioning)

This single apply creates the VPC, RDS, Redis, ECR, ECS cluster, ALB, Cognito, and Secrets Manager. It takes 15–20 minutes.

6a — Plan first, review before applying

terraform plan -out=tfplan 2>&1 | tee /tmp/tf-plan.txt

Review the plan output. You should see approximately 50–60 resources to create. Key things to verify:

  • module.vpc — VPC, 6 subnets, NAT gateway, route tables
  • module.rdsaws_db_instance.main with engine = "postgres", engine_version = "16"
  • module.ecs_cluster — ECS cluster named construo-dev
  • module.cognito — User pool named construo-dev, domain construo-dev
  • module.secrets — Secrets Manager secret construo/dev/app

If anything looks wrong, do not apply. Paste the plan to Claude with the question.

6b — Apply

terraform apply tfplan

This will prompt once: Do you want to perform these actions? yes

Watch the output. The most likely thing to fail is the RDS instance (it takes the longest). If it fails, check the error, fix the issue, and re-run terraform plan -out=tfplan && terraform apply tfplan.

6c — Save the outputs

terraform output

Copy all output values to 1Password under AWS → Dev Terraform Outputs. You'll need these throughout the rest of the setup. The key ones:

  • alb_dns_name — the ALB's public DNS name
  • ecr_repository_url — where you'll push Docker images
  • cognito_user_pool_id — goes into .env
  • cognito_client_id — goes into .env
  • rds_endpoint — RDS hostname

Step 7 — Build and push the Docker image

Before ECS can run anything, the image needs to be in ECR.

7a — Authenticate Docker to ECR

# Replace 123456789012 with your actual AWS account ID
aws ecr get-login-password --region eu-west-2 | \
  docker login --username AWS --password-stdin \
  123456789012.dkr.ecr.eu-west-2.amazonaws.com

Expected: Login Succeeded

7b — Build the image

cd apps/api
docker build -t construo-api:latest .

Expected: build completes with Successfully built [id] or naming to docker.io/library/construo-api:latest. The first build takes 3–5 minutes; subsequent builds are faster due to layer caching.

7c — Tag and push

# Get the ECR URL from Terraform outputs (or run: terraform output ecr_repository_url)
ECR_URL=$(cd infra/terraform/environments/dev && terraform output -raw ecr_repository_url)

docker tag construo-api:latest $ECR_URL:latest
docker push $ECR_URL:latest

Expected: layers uploading, ends with latest: digest: sha256:...


Step 8 — Bootstrap the database

The API needs the public schema (tenants table, functions) before it can serve requests.

8a — Get the database connection string

# Get RDS endpoint from Terraform
RDS_ENDPOINT=$(cd infra/terraform/environments/dev && terraform output -raw rds_endpoint)

# Get the database password from Secrets Manager
DB_PASS=$(aws secretsmanager get-secret-value \
  --secret-id construo/dev/db-password \
  --query SecretString --output text)

echo "postgresql://construo:$DB_PASS@$RDS_ENDPOINT/construo"

The RDS instance is in a private subnet — you can't connect from your laptop directly. You have two options:

Option A (recommended): Use an EC2 bastion temporarily

# Launch a micro bastion in a public subnet (cheapest instance)
SUBNET_ID=$(cd infra/terraform/environments/dev && \
  terraform output -json public_subnet_ids | python3 -c "import sys,json; print(json.load(sys.stdin)[0])")
VPC_ID=$(cd infra/terraform/environments/dev && terraform output -raw vpc_id 2>/dev/null || \
  aws ec2 describe-vpcs --filters "Name=tag:Name,Values=construo-dev" --query "Vpcs[0].VpcId" --output text)

# Get your IP
MY_IP=$(curl -s https://api.ipify.org)

# Create a temporary security group
BASTION_SG=$(aws ec2 create-security-group \
  --group-name construo-bastion-temp \
  --description "Temporary bastion" \
  --vpc-id $VPC_ID \
  --query GroupId --output text)

aws ec2 authorize-security-group-ingress \
  --group-id $BASTION_SG \
  --protocol tcp \
  --port 22 \
  --cidr $MY_IP/32

# Allow bastion to reach RDS (get RDS SG from Terraform)
RDS_SG=$(cd infra/terraform/environments/dev && terraform output -raw rds_security_group_id 2>/dev/null || \
  aws ec2 describe-security-groups \
    --filters "Name=group-name,Values=construo-dev-rds" \
    --query "SecurityGroups[0].GroupId" --output text)

aws ec2 authorize-security-group-ingress \
  --group-id $RDS_SG \
  --protocol tcp \
  --port 5432 \
  --source-group $BASTION_SG

# Launch bastion (use your key pair — create one in EC2 console if you don't have one)
BASTION_ID=$(aws ec2 run-instances \
  --image-id $(aws ec2 describe-images \
    --owners amazon \
    --filters "Name=name,Values=al2023-ami-*-x86_64" \
    --query "sort_by(Images, &CreationDate)[-1].ImageId" \
    --output text) \
  --instance-type t3.micro \
  --subnet-id $SUBNET_ID \
  --security-group-ids $BASTION_SG \
  --associate-public-ip-address \
  --key-name YOUR_KEY_PAIR_NAME \
  --query "Instances[0].InstanceId" \
  --output text)

echo "Bastion ID: $BASTION_ID"
aws ec2 wait instance-running --instance-ids $BASTION_ID
BASTION_IP=$(aws ec2 describe-instances \
  --instance-ids $BASTION_ID \
  --query "Reservations[0].Instances[0].PublicIpAddress" \
  --output text)
echo "Bastion IP: $BASTION_IP"

Connect and run migrations:

# Copy the API code and run alembic on the bastion
ssh -i ~/.ssh/YOUR_KEY.pem ec2-user@$BASTION_IP

# On the bastion:
sudo yum install -y python3-pip git postgresql15
pip3 install uv

# Clone or scp the repo (simpler: scp from your machine)
# From your laptop (new terminal):
scp -i ~/.ssh/YOUR_KEY.pem -r apps/api ec2-user@$BASTION_IP:~/api

# Back on bastion:
cd ~/api
python3 -m venv .venv && source .venv/bin/activate
pip install -r <(pip show uv 2>/dev/null || echo "uv") && uv pip install -e .

export DATABASE_URL="postgresql+asyncpg://construo:PASSWORD@RDS_ENDPOINT/construo"
export DATABASE_URL_DIRECT="$DATABASE_URL"
export ENVIRONMENT=dev
export COGNITO_USER_POOL_ID=placeholder
export COGNITO_REGION=eu-west-2
export COGNITO_CLIENT_ID=placeholder
export APP_DB_USER=construo
export KMS_TENANT_KEY_ARN=arn:aws:kms:eu-west-2:123456789012:key/placeholder
export OFFLINE_TOKEN_SECRET=placeholder

# Run public schema migrations
alembic upgrade head

Option B: Run migrations as an ECS one-off task

After the service is deployed (step 9), you can run:

# Run a one-off migration task
aws ecs run-task \
  --cluster construo-dev \
  --task-definition construo-dev-api \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[PRIVATE_SUBNET_ID],securityGroups=[ECS_SG_ID],assignPublicIp=DISABLED}" \
  --overrides '{"containerOverrides":[{"name":"api","command":["alembic","upgrade","head"]}]}'

8b — Verify the migrations ran

# On the bastion or in the ECS task logs:
# Check that public.tenants table exists
psql "postgresql://construo:$DB_PASS@$RDS_ENDPOINT/construo" \
  -c "\dt public.*"

Expected: tables including tenants, tenant_migrations, identities, tenant_memberships.

8c — Terminate the bastion (if you created one)

aws ec2 terminate-instances --instance-ids $BASTION_ID

# Clean up the temporary SG rule on RDS
aws ec2 revoke-security-group-ingress \
  --group-id $RDS_SG \
  --protocol tcp \
  --port 5432 \
  --source-group $BASTION_SG

aws ec2 delete-security-group --group-id $BASTION_SG

Step 9 — Deploy to ECS and verify health

9a — Force a new deployment

aws ecs update-service \
  --cluster construo-dev \
  --service construo-api-dev \
  --force-new-deployment \
  --region eu-west-2

9b — Watch the deployment

aws ecs wait services-stable \
  --cluster construo-dev \
  --services construo-api-dev \
  --region eu-west-2

This command blocks until the service is stable. It can take 3–5 minutes. If it times out, check the ECS console for task failures and the CloudWatch logs.

9c — Verify health via the ALB

ALB_DNS=$(cd infra/terraform/environments/dev && terraform output -raw alb_dns_name)
curl http://$ALB_DNS/health

Expected:

{"status": "ok"}

If you get a 502, the task hasn't started yet. Wait 30 seconds and try again.


Step 10 — Set up GitHub and CI/CD

10a — Create the GitHub repository

gh auth login   # follow the prompts, choose HTTPS, browser auth

# Create the repo (if not already done)
gh repo create construo-io/platform --private --source=. --push

10b — Create the GitHub Actions deploy role

CI/CD uses OIDC (not long-lived keys) to authenticate to AWS. This is more secure.

# Create the OIDC identity provider in AWS (one-time setup)
aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
  --thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1

# Get the OIDC provider ARN
OIDC_ARN=$(aws iam list-open-id-connect-providers \
  --query "OpenIDConnectProviderList[?contains(Arn,'token.actions')].Arn" \
  --output text)

# Create the deploy role
ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)

aws iam create-role \
  --role-name construo-github-deploy \
  --assume-role-policy-document "{
    \"Version\": \"2012-10-17\",
    \"Statement\": [{
      \"Effect\": \"Allow\",
      \"Principal\": { \"Federated\": \"$OIDC_ARN\" },
      \"Action\": \"sts:AssumeRoleWithWebIdentity\",
      \"Condition\": {
        \"StringLike\": {
          \"token.actions.githubusercontent.com:sub\": \"repo:construo-io/platform:*\"
        },
        \"StringEquals\": {
          \"token.actions.githubusercontent.com:aud\": \"sts.amazonaws.com\"
        }
      }
    }]
  }"

# Attach the permissions the CI pipeline needs
aws iam attach-role-policy \
  --role-name construo-github-deploy \
  --policy-arn arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryPowerUser

aws iam create-policy \
  --policy-name construo-ecs-deploy \
  --policy-document "{
    \"Version\": \"2012-10-17\",
    \"Statement\": [
      {
        \"Effect\": \"Allow\",
        \"Action\": [
          \"ecs:UpdateService\",
          \"ecs:DescribeServices\",
          \"ecs:DescribeTaskDefinition\",
          \"ecs:RegisterTaskDefinition\",
          \"iam:PassRole\"
        ],
        \"Resource\": \"*\"
      },
      {
        \"Effect\": \"Allow\",
        \"Action\": [\"secretsmanager:GetSecretValue\"],
        \"Resource\": \"arn:aws:secretsmanager:eu-west-2:$ACCOUNT_ID:secret:construo/dev/*\"
      }
    ]
  }"

aws iam attach-role-policy \
  --role-name construo-github-deploy \
  --policy-arn arn:aws:iam::$ACCOUNT_ID:policy/construo-ecs-deploy

DEPLOY_ROLE_ARN="arn:aws:iam::$ACCOUNT_ID:role/construo-github-deploy"
echo "Deploy role ARN: $DEPLOY_ROLE_ARN"

10c — Add GitHub repository secrets

gh secret set AWS_DEPLOY_ROLE_ARN --body "$DEPLOY_ROLE_ARN"

# These are used by the web deploy job (add real values once you have them)
gh secret set WEB_S3_BUCKET_DEV --body "construo-web-dev"
gh secret set CF_DISTRIBUTION_DEV --body "PLACEHOLDER"  # update in step 12

10d — Push and verify CI

git add -A
git commit -m "chore: add CI/CD, Terraform modules, and Dockerfile"
git push origin main

Go to the Actions tab in GitHub. You should see the API CI workflow running. The test job runs lint, type-check, and pytest. The deploy job only runs on pushes to main.

If the test job fails, check the logs. The most common cause is a missing env var in the workflow file.


Step 11 — Configure Cognito fully

Terraform created the User Pool shell, but you need to configure hosted UI details and create the first test user via the console.

11a — Verify the hosted UI domain

Open the AWS Console → Cognito → User pools → construo-dev → App integration.

Confirm: - Domain: construo-dev.auth.eu-west-2.amazoncognito.com — shows "Available" - App client: construo-web-dev - Callback URL: http://localhost:5173/auth/callback

11b — Create a test user

USER_POOL_ID=$(cd infra/terraform/environments/dev && terraform output -raw cognito_user_pool_id)

aws cognito-idp admin-create-user \
  --user-pool-id $USER_POOL_ID \
  --username admin@yourdomain.com \
  --temporary-password "Construo.Dev.2025!" \
  --user-attributes \
    Name=email,Value=admin@yourdomain.com \
    Name=email_verified,Value=true \
    Name=name,Value="Gari Admin"

This user can sign in via the Cognito hosted UI.

11c — Update the frontend .env.local

Back in your local repo, update apps/web/.env.local:

# Get values from Terraform outputs
USER_POOL_ID=$(cd infra/terraform/environments/dev && terraform output -raw cognito_user_pool_id)
CLIENT_ID=$(cd infra/terraform/environments/dev && terraform output -raw cognito_client_id)
DOMAIN=$(cd infra/terraform/environments/dev && terraform output -raw cognito_domain)

cat > apps/web/.env.local << EOF
VITE_API_BASE_URL=
VITE_COGNITO_USER_POOL_ID=$USER_POOL_ID
VITE_COGNITO_REGION=eu-west-2
VITE_COGNITO_CLIENT_ID=$CLIENT_ID
VITE_COGNITO_DOMAIN=$DOMAIN
VITE_POWERSYNC_URL=
VITE_ENVIRONMENT=dev
VITE_DEV_BYPASS_AUTH=false
EOF

Step 12 — Provision the first tenant

This creates the tenant row, Postgres schema, and runs migrations for that schema.

12a — Connect to the database

Use the bastion approach from step 8, or run as an ECS task:

# Run the provision script as an ECS one-off task
PRIVATE_SUBNET=$(cd infra/terraform/environments/dev && \
  terraform output -json private_subnet_ids 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)[0])" || \
  aws ec2 describe-subnets \
    --filters "Name=tag:Tier,Values=private" "Name=tag:Project,Values=construo" \
    --query "Subnets[0].SubnetId" --output text)
ECS_SG=$(aws ec2 describe-security-groups \
  --filters "Name=group-name,Values=construo-dev-ecs" \
  --query "SecurityGroups[0].GroupId" --output text)

aws ecs run-task \
  --cluster construo-dev \
  --task-definition construo-dev-api \
  --launch-type FARGATE \
  --network-configuration "awsvpcConfiguration={subnets=[$PRIVATE_SUBNET],securityGroups=[$ECS_SG],assignPublicIp=DISABLED}" \
  --overrides '{
    "containerOverrides": [{
      "name": "api",
      "command": [
        "python", "scripts/provision_dev_tenant.py",
        "--slug", "acme",
        "--name", "Acme Construction Ltd"
      ]
    }]
  }'

12b — Verify the tenant schema exists

Via psql (through the bastion):

SELECT slug, schema_name, status FROM public.tenants;
-- Expected: acme | t_acme | active

SELECT schemaname FROM pg_catalog.pg_namespace WHERE nspname = 't_acme';
-- Expected: 1 row

Step 13 — Update the API .env with real Cognito values

Back in apps/api/.env, update these values with the real ones from Terraform:

COGNITO_USER_POOL_ID=eu-west-2_XXXXXXXXX   # from terraform output
COGNITO_CLIENT_ID=xxxxxxxxxxxxxxxxxxxx      # from terraform output
COGNITO_DOMAIN=construo-dev.auth.eu-west-2.amazoncognito.com

These are now in Secrets Manager on AWS. For local dev the dev bypass still works (the existing .env.local with VITE_DEV_BYPASS_AUTH=true still works). For the deployed instance, ECS reads from Secrets Manager directly.


Step 14 — End-to-end verification

Work through every item below and confirm it passes.

14a — API health (via ALB)

ALB=$(cd infra/terraform/environments/dev && terraform output -raw alb_dns_name)
curl http://$ALB/health
# Expected: {"status":"ok"}

14b — API returns 404 for unknown tenant (correct behaviour)

curl http://$ALB/projects -H "Host: notexist.construo.build"
# Expected: {"error":"not_found","message":"Tenant not found"}

14c — Cognito login flow works

  1. Run the frontend locally: cd apps/web && npm run dev (with VITE_DEV_BYPASS_AUTH=false)
  2. Navigate to http://localhost:5173
  3. You should be redirected to the Cognito hosted UI at construo-dev.auth.eu-west-2.amazoncognito.com
  4. Sign in with the test user credentials from step 11b
  5. Change the temporary password when prompted
  6. You should be redirected back to http://localhost:5173/auth/callback then the dashboard

14d — AWS Console checks

Check Where to look Expected
ECS task running ECS → construo-dev → Services → construo-api-dev 1/1 tasks running
RDS available RDS → construo-dev Status: Available
CloudWatch logs CloudWatch → Log groups → /ecs/construo-dev-api Recent request logs
Secrets Manager Secrets Manager → construo/dev/app Secret exists (don't reveal)

14e — Cost check

Go to Billing → Cost Explorer → view last 24 hours. With a single ECS task, t3.medium RDS, t3.micro Redis, and NAT gateway, you should see under £5/day. If you see significantly more, check for unexpected resources.


Step 15 — DNS setup (when ready to access via construo.build)

This step is only needed when you're ready to make the app accessible via *.construo.build. You can skip this and use the ALB DNS name for now.

15a — Request ACM certificate

The cert must be in eu-west-2 for the ALB, and a separate cert in us-east-1 for CloudFront (when you add CloudFront later).

# For ALB (eu-west-2)
aws acm request-certificate \
  --domain-name "*.construo.build" \
  --subject-alternative-names "construo.build" \
  --validation-method DNS \
  --region eu-west-2

# Note the CertificateArn from the output

15b — Add DNS validation records

CERT_ARN="arn:aws:acm:eu-west-2:ACCOUNT:certificate/CERT_ID"
aws acm describe-certificate \
  --certificate-arn $CERT_ARN \
  --query "Certificate.DomainValidationOptions[0].ResourceRecord"

Add the CNAME record it shows to your DNS provider. Wait for validation (5–30 minutes).

15c — Add the ALB record to DNS

Add a CNAME record at your domain registrar:

api.construo.build  →  [alb_dns_name from terraform output]
*.construo.build    →  [alb_dns_name from terraform output]

15d — Update Terraform to use HTTPS

Uncomment the certificate_arn line in infra/terraform/environments/dev/main.tf:

module "alb" {
  ...
  certificate_arn = "arn:aws:acm:eu-west-2:ACCOUNT_ID:certificate/CERT_ID"
}

Update Cognito callback URLs to include your domain:

module "cognito" {
  ...
  callback_urls = [
    "http://localhost:5173/auth/callback",
    "https://acme.construo.build/auth/callback",
  ]
  logout_urls = [
    "http://localhost:5173",
    "https://acme.construo.build",
  ]
}

Then:

terraform plan -out=tfplan
terraform apply tfplan

# Verify HTTPS works
curl https://api.construo.build/health

Common problems and fixes

Problem Most likely cause Fix
terraform apply fails on RDS Password has special chars the DB rejects random_password override_special in RDS module is set correctly — re-run apply
ECS task keeps failing Wrong image tag or missing secret Check CloudWatch log group /ecs/construo-dev-api
502 from ALB Task not healthy yet, or health check failing Wait 2 minutes; check /health endpoint works in the container
Cognito redirect 400 Callback URL mismatch Callback URL in Cognito must exactly match the URL the app uses
Migrations fail Can't reach RDS ECS security group must allow 5432 to RDS security group — Terraform handles this
terraform init fails S3 bucket doesn't exist yet Run step 4 first
CI deploy job fails with 403 OIDC role trust policy too narrow Add the branch ref to the trust condition in the role

What comes next

Once this verification passes, Phase 0 is complete. The next priorities are:

  1. Add the pre-token Lambda — the Cognito Lambda trigger in infra/lambda/cognito_pre_token/handler.py needs to be deployed and wired to the User Pool. This adds custom:tenant_id, custom:tenant_slug, custom:identity_id, and custom:platform_roles to every JWT.
  2. Deploy the frontend — build the React app and upload to S3 + CloudFront.
  3. PowerSync — connect the offline sync engine to RDS.
  4. Staging environment — copy this process for staging using infra/terraform/environments/staging/.