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.
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.
Current inventory: Infrastructure ·
AWS system map.
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:
- Mobile backend —
backend/server.py(FastAPI + MongoDB) on Render. Governed by ADR-0001. Not in scope here. - Web backend —
backend-postgres/(FastAPI + PostgreSQL via asyncpg) that servesapi.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:
Current hosts are ECS + RDS. See Infrastructure and the AWS system map.
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.toresolves to18.116.42.204, a single AWS Lightsail VM running the FastAPI app undersystemdbehind 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.tohasCN=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
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 ( | ~$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
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.tocert, so iOS ATS acceptsapi.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
systemdrestart and ad-hoc dumps. - Cutover is low-risk. Keeping the
api.drape.tohostname (ACM + DNS flip) means Stripe webhooks and the app'sEXPO_PUBLIC_BACKEND_URLneed 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-keyssecret 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_dispatchper 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 page after cutover to show ECS/RDS instead of Lightsail. Done 2026-09-17 — plus AWS system map.
- 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.