Skip to main content

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.
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 liveapi.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:

  1. Mobile backendbackend/server.py (FastAPI + MongoDB) on Render. Governed by ADR-0001. Not in scope here.
  2. Web backendbackend-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:

Historical context — pre-cutover

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

ECS Fargate — public subnets, no NAT gateway

prod host

dev host

secrets

Internet

ACM + ALB (one, shared)
prod host + dev host, 443

prod: drape-api x1
(briefly x2 during deploys)

dev: drape-api x1

RDS PG 16 - prod
db.t4g.small, single-AZ, private

RDS PG 16 - dev
db.t4g.micro, already built

Upstash Redis

Supabase / Stripe / FAL / Resend / Mux

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)

ItemMonthly
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

P0 Prep
Dockerfile, ECR
DONE

P1 Infra
VPC, RDS, ALB, ECS
IN PROGRESS

P2 Dev env
develop to dev
+ cutover rehearsal

P3 CI/CD
Actions to ECR to ECS via OIDC

P4 Prod + cutover
Lightsail PG to RDS
flip api host to ALB

P5 Decommission
retire Lightsail
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 todayReplaced by
FastAPI app (backend-postgres, systemd)ECS Fargate service
PostgreSQL database (location TBC)RDS
nginx reverse proxyALB
certbot / TLS (staging.drape.to cert)ACM on the ALB
/opt/drape/.env secretsSecrets Manager
The Postgres data itselfdump/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 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.