# ADR-0002 — Migrate the Postgres backend from Lightsail to ECS Fargate + RDS

- **Status:** Accepted
- **Date:** 2026-08-26
- **Deciders:** Wes (engineering), Kenji (owner). Reconciled against an external ECS/RDS migration proposal.

:::tip[Implementation status — 2026-08-31: CUTOVER COMPLETE; docs refreshed 2026-09-17]
**Dev is live** on ECS Fargate + RDS (healthy behind the ALB; `develop` → dev CI/CD proven end-to-end).
**Prod is live** — `api.drape.to` serves from the ECS **`drape-prod` service** on cluster **`drape`** over a valid ACM cert
(`drape-postgres-prod-live` holds the restored live data — docs-supported `DATABASE_URL` host; not secret-read re-proven).
Lightsail was already gone; the cutover was executed on 2026-08-31. Full record:
[Production cutover — api.drape.to → ECS/RDS](../shipping/prod-cutover.md).
Current inventory: [Infrastructure](../architecture/infrastructure.md) ·
[AWS system map](../architecture/aws-system-map.md).
No marketing/front-end (`drape.to`, Amplify) resources were touched by the cutover.
:::

## Context

Drape runs **two** backends, and this ADR is about the second one:

1. **Mobile backend** — `backend/server.py` (FastAPI + MongoDB) on **Render**. Governed by [ADR-0001](./0001-gated-ci-cd-deploy-pipeline.md). **Not** in scope here.
2. **Web backend** — `backend-postgres/` (FastAPI + PostgreSQL via asyncpg) that serves `api.drape.to`. This is what we are migrating.

Verified facts **at ADR-write time** (live `dig` + a running container, 2026-08-26).
These Lightsail bullets are **historical — not current production**:

:::note[Historical context — pre-cutover]
Current hosts are ECS + RDS. See [Infrastructure](../architecture/infrastructure.md)
and the [AWS system map](../architecture/aws-system-map.md).

The infrastructure page previously described `api.drape.to` as Render-hosted, then
as Lightsail. Those revisions are **STALE**. This ADR remains the decision record
for the migration; it is **not** the live topology.
:::

- `api.drape.to` resolves to **`18.116.42.204`**, a single **AWS Lightsail** VM running the FastAPI app under `systemd` behind **nginx**, with **certbot** for TLS. The app talks to a **PostgreSQL** database; whether that database is **co-located on the box or a managed instance the box connects to is unconfirmed** and must be pinned down before cutover (the dump/restore is agnostic to which). Either way, the current web tier's app host is a single point of failure.
- The TLS certificate presented on `api.drape.to` has **`CN=staging.drape.to`** — a hostname mismatch. **iOS App Transport Security rejects the connection**, so mobile calls to that host fail today. This is an App-Store blocker.
- The app's health endpoint is **`/api/status`** (returns `{"status":"ok","database":"postgresql"}`), *not* `/api/health` — that path belongs to the Mongo backend.
- The app uses **WebSockets** (`ws_manager`, Mux live, explore-realtime), which rules out App Runner and requires an ALB.

An external ECS/RDS migration proposal independently landed on **ECS Fargate + ALB + RDS PostgreSQL 16** — the same shape we had already started building, so the plans converged. The open questions were **how many environments**, **how much to spend**, and **what happens to the Lightsail box**.

## Decision

**Migrate the Postgres web backend to ECS Fargate behind one ACM-terminated ALB, with RDS PostgreSQL 16 as the database. Run exactly two environments — `dev` and `prod`, no staging — on a cost-optimized profile (~$118/mo). After a rollback window, decommission the Lightsail box entirely.**

### Target architecture

```mermaid
flowchart TB
    Internet([Internet]) --> ALB["ACM + ALB (one, shared)<br/>prod host + dev host, 443"]
    ALB -->|prod host| Prod["prod: drape-api x1<br/>(briefly x2 during deploys)"]
    ALB -->|dev host| Dev["dev: drape-api x1"]
    subgraph ECS["ECS Fargate — public subnets, no NAT gateway"]
      Prod
      Dev
    end
    Prod --> RDSp[("RDS PG 16 - prod<br/>db.t4g.small, single-AZ, private")]
    Dev --> RDSd[("RDS PG 16 - dev<br/>db.t4g.micro, already built")]
    ECS --> Redis[("Upstash Redis")]
    ECS --> SaaS["Supabase / Stripe / FAL / Resend / Mux"]
    ECS -.->|secrets| SM["AWS Secrets Manager"]
```

One ALB host-routes `api.drape.to` → prod and a `dev-api` host → dev. Both Fargate services run in **public subnets** (no NAT gateway) but only accept inbound from the **ALB security group**; RDS only accepts inbound from the **task security group**. TLS terminates at the ALB via an **ACM** cert for `api.drape.to`, which is what fixes the `staging.drape.to` mismatch. Redis (Upstash), Supabase, and the SaaS APIs stay where they are; secrets inject from Secrets Manager.

### Two environments, not three

The founder's original goal was `dev → staging → prod`. We drop **staging** and let **dev double as the cutover-rehearsal environment**: the Lightsail→RDS dump/restore and the full smoke test run against dev *before* prod is touched. This preserves the hard rule — *never move production in one untested step* — without paying for a third environment. Staging can be added later as a pure config change if release volume ever justifies it.

### Cost-optimized profile (~$118/mo)

| Item | Monthly |
|---|---|
| RDS dev — `db.t4g.micro` single-AZ (already built) | ~$15 |
| RDS prod — `db.t4g.small` single-AZ + backups | ~$30 |
| ALB — one shared, host-routed | ~$22 |
| Fargate — dev (~$9) + prod (~$36) | ~$45 |
| NAT gateway | $0 (public subnets) |
| ECR + Secrets Manager + CloudWatch | ~$6 |
| **Total** | **~$118/mo** |

This is roughly **half** the full-hardened 3-env target (~$250–290/mo). The levers: single-AZ RDS, public subnets (no NAT), prod at one task, and one shared ALB. Retiring Lightsail removes its current charge, so *net* new spend is lower than the sticker. A ~$103 variant puts both databases on one `t4g.micro`, trading prod isolation — acceptable only until real load.

**Hardening path** (add when metrics justify, each a config change not a redesign): prod Multi-AZ (+$30–80), a 2nd always-on prod task for live AZ redundancy (+$36), a dedicated staging env (+$30–40).

### Phases

```mermaid
flowchart LR
    P0["P0 Prep<br/>Dockerfile, ECR<br/>DONE"] --> P1["P1 Infra<br/>VPC, RDS, ALB, ECS<br/>IN PROGRESS"]
    P1 --> P2["P2 Dev env<br/>develop to dev<br/>+ cutover rehearsal"]
    P2 --> P3["P3 CI/CD<br/>Actions to ECR to ECS via OIDC"]
    P3 --> P4["P4 Prod + cutover<br/>Lightsail PG to RDS<br/>flip api host to ALB"]
    P4 --> P5["P5 Decommission<br/>retire Lightsail<br/>after rollback window"]
```

**Already shipped (P0 + part of P1):** the `Dockerfile` + `.dockerignore` (`uvicorn server:app`, `/api/status` healthcheck, committed to `develop`), the ECR repo, VPC + DB security group + subnet group, the `drape-postgres` RDS instance (PG 16, `db.t4g.micro` — now the **dev** database), and the `drape/backend/shared-keys` Secrets Manager scaffold. Local verification passed: image builds, schema + 29 migrations apply to **49 tables**, the container boots against Postgres, and `/api/status` returns 200.

### Decommission Lightsail

Once prod is stable on ECS/RDS and `api.drape.to` points at the ALB, the Lightsail box has no remaining job — every function it performs is replaced:

| Lightsail box does today | Replaced by |
|---|---|
| FastAPI app (`backend-postgres`, systemd) | ECS Fargate service |
| PostgreSQL database (location TBC) | RDS |
| nginx reverse proxy | ALB |
| certbot / TLS (`staging.drape.to` cert) | ACM on the ALB |
| `/opt/drape/.env` secrets | Secrets Manager |
| The Postgres data itself | dump/restored into RDS at cutover |

The box is kept **warm for 48–72 h** after cutover as a rollback path, then retired on this checklist: (1) confirm zero traffic to `18.116.42.204` and that the old DNS TTL has expired; (2) final `pg_dump` → encrypted, versioned S3; (3) snapshot the instance (retain ~30 days); (4) rotate any secret that lived only in `/opt/drape/.env`; (5) release the static IP; (6) delete the instance and any Lightsail-managed DB/LB; (7) verify billing drops; (8) purge nginx/certbot/systemd steps and the old IP from runbooks, DNS, allowlists, and monitoring. Hold the S3 dump and snapshot for ~30 days, then delete those too.

**Scope:** this retires the Lightsail (web) backend only. The MongoDB backend on Render is untouched; the Mongo → Postgres consolidation is a separate, later effort. After this migration the platform is **ECS/RDS (was Lightsail) + Render/Mongo (mobile)**.

## Consequences

**Easier / better**

- **Fixes the App-Store blocker.** ACM on the ALB replaces the `staging.drape.to` cert, so iOS ATS accepts `api.drape.to`.
- **Removes the SPOF.** App, DB, and proxy no longer share one VM; ECS does rolling deploys and RDS does automated backups instead of a `systemd` restart and ad-hoc dumps.
- **Cutover is low-risk.** Keeping the `api.drape.to` hostname (ACM + DNS flip) means Stripe webhooks and the app's `EXPO_PUBLIC_BACKEND_URL` need no coordinated release; dev rehearses the restore first.
- **Cheaper than the alternative hardened build**, and Lightsail's retired cost offsets part of the new spend.

**New obligations**

- Fill the **`drape/backend/shared-keys`** secret in Secrets Manager from the current Render/Lightsail env before dev can boot.
- **Lock RDS private** before prod and run migrations via a one-off ECS task (not from a laptop).
- Set up **GitHub OIDC** so Actions can push to ECR / update ECS without long-lived AWS keys; prod deploys stay `workflow_dispatch` per ADR-0001's gate philosophy.
- Execute the **decommission checklist** — releasing the static IP and deleting the instance are irreversible; do them only after the rollback window closes.
- Refresh the [Infrastructure](../architecture/infrastructure.md) page after cutover to show ECS/RDS instead of Lightsail. **Done 2026-09-17** — plus [AWS system map](../architecture/aws-system-map.md).
- **Codify the infra as CDK** for reproducibility (currently imperative AWS CLI).

## Alternatives considered

- **AWS App Runner instead of ECS + ALB.** Rejected: no WebSocket support, and the app depends on WS (`ws_manager`, Mux, explore).
- **Keep the Lightsail box, just fix the cert.** Rejected: leaves the single-VM SPOF (app + DB + proxy on one host) and ad-hoc backups; doesn't get us rolling deploys or a dev/prod split.
- **Three environments (dev + staging + prod).** Rejected for now: staging adds ~$30–40/mo and operational overhead; dev-as-rehearsal gives the same "test before prod" guarantee at 11 users. Revisit when release volume justifies it.
- **Full-hardened build now (prod Multi-AZ, 2 tasks, NAT, private subnets).** Rejected as premature: ~$250–290/mo for redundancy the current load doesn't need. Adopted the lean profile with a documented hardening path instead.
- **Aurora / EKS.** Rejected as over-engineered for a solo-founder app — RDS PostgreSQL on Fargate is the right size.
- **Consolidate Mongo → Postgres as part of this migration.** Rejected: too much blast radius in one move. This ADR is strictly the Lightsail → ECS/RDS lift; the data-model consolidation is a separate future ADR.
