LastVet → AWS migration assessment¶
Refreshed: Jul 11, 2026 (after 046–048 applied to Railway prod)
Status: Plan only. No infra or code changes yet.
Scope: last.vet-api + Postgres on Railway. Provider portal and marketing site stay on Cloudflare Pages. Account/BAA setup is out of scope (Ryan).
| DECIDED | Bake dual URL + dual DB secrets from day one |
| Status | Prod and staging at parity (49 migrations, lastvet_app, RLS batch 1) |
| Info | Prod DB still 13 MB |
| Recommend | Terraform + ECS Fargate |
Quick stats: Prod Postgres 13 MB · Staging 12 MB · Both at 49 migrations · PG 18.4 on both
Decided: dual URL + dual DB secrets (locked)¶
AWS staging and prod must ship with:
| Secret / env | Role | Used by |
|---|---|---|
DATABASE_APP_URL |
lastvet_app (NOBYPASSRLS) |
ECS service runtime pool |
MIGRATION_DATABASE_URL |
RDS master / owner | Migrate jobs + ensureCoreSchema |
Single-superuser DATABASE_URL-only is not an acceptable cutover shape.
Dual DB secrets in Secrets Manager: separate secrets (and passwords) for the RDS master user and lastvet_app. Do not store both URLs in one secret blob.
Railway prod now proves this pattern:
DATABASE_APP_URL=postgresql://lastvet_app:***@postgres-lastvet-prod.railway.internal:5432/railway
MIGRATION_DATABASE_URL=postgresql://postgres:***@postgres-lastvet-prod.railway.internal:5432/railway
Fresh RDS on AWS: run migrations 001–048 with master URL, provision lastvet_app password, inject both URLs into ECS task secrets before marking the service healthy.
Prod/staging parity (Jul 11)¶
Both environments are now aligned:
| Fact | Production | Staging |
|---|---|---|
| Migrations | 49 (001–048) |
49 (001–048) |
lastvet_app role |
Yes (NOBYPASSRLS) | Yes (NOBYPASSRLS) |
| RLS tables | 4 (health_record_items, documents, provider_notes, self_reported_entries) |
Same |
| Dual URLs on API | DATABASE_APP_URL + MIGRATION_DATABASE_URL |
Same |
| DB size | 13 MB | 12 MB |
No staging/prod schema drift remains. AWS can mirror Railway prod's dual-URL shape directly.
Scope correction (unchanged)¶
- Provider portal is Cloudflare Pages (
last-vet-provider), not Railway. - Marketing site (
last.vet-web) is Cloudflare Pages, not Railway. - Only API + Postgres move to AWS.
- Clients keep calling
api.last.vetafter DNS cutover.
1. Inventory (Railway today)¶
Services¶
| Component | Railway / host | Notes |
|---|---|---|
| API (prod) | last.vet-api @ api.last.vet |
Project disciplined-beauty; nixpacks + railway-start.sh; /health |
| API (staging) | last.vet-api-staging |
lastvet-api-staging-staging.up.railway.app; RUN_MIGRATIONS_ON_BOOT=true |
| Postgres (prod) | postgres-lastvet-prod |
13 MB; PG 18.4; 49 migrations; lastvet_app + RLS batch 1 |
| Postgres (staging) | postgres-lastvet-staging |
12 MB; PG 18.4; 49 migrations; lastvet_app + RLS batch 1 |
| Provider portal | Cloudflare Pages (not Railway) | Stays put; NEXT_PUBLIC_API_URL → api.last.vet |
| Volume (uploads) | /data → UPLOAD_DIR=/data/lastvet-uploads |
Both envs; local disk via multer |
API runtime shape¶
- Single Express process on
PORT - No Redis / worker / SQS
- In-process last1 proof drainer (60s interval)
- Boot runs
ensureCoreSchema()viamigrationDb(owner URL) - Runtime pool prefers
DATABASE_APP_URLoverDATABASE_URL Dockerfileexists; Railway still uses nixpacks +railway-start.sh
Postgres snapshot (exact counts, Jul 11)¶
| Fact | Production | Staging |
|---|---|---|
| Size | 13 MB | 12 MB |
| Engine | PostgreSQL 18.4 | PostgreSQL 18.4 |
| Extensions | pgcrypto 1.4, plpgsql |
pgcrypto, plpgsql |
schema_migrations |
49 (001–048) |
49 (001–048) |
| veterans | 16 | 25 |
| providers | 16 | 53 |
| health_record_items | 420 | 15 |
| consent_grants | 46 | 32 |
| documents | 16 | 3 |
| waitlist_signups | 8 | 5 |
| RLS tables | 4 (batch 1 + 042) |
Same |
| App role | lastvet_app NOBYPASSRLS |
Same |
| DB URLs on API service | DATABASE_APP_URL + MIGRATION_DATABASE_URL + DATABASE_URL |
Same |
RLS batch 1 (046–048) — live on both envs¶
| Migration | What it does | AWS cutover note |
|---|---|---|
046_phi_tables_rls_batch1 |
FORCE RLS on documents, provider_notes, self_reported_entries |
Requires DATABASE_APP_URL; master bypasses RLS |
047_lastvet_app_role |
Creates lastvet_app (NOBYPASSRLS) + GRANTs |
Password via Secrets Manager, not in SQL |
048_lastvet_app_connect_grant |
GRANT CONNECT ON DATABASE to lastvet_app |
Grant on actual RDS database name |
Env vars / secrets (app-owned)¶
Names only. Dual-URL + dual secrets is required on AWS.
| Category | Variables | Required for cutover? |
|---|---|---|
| Core / DB | DATABASE_APP_URL (runtime lastvet_app), MIGRATION_DATABASE_URL (owner), DATABASE_URL (legacy fallback), PORT, NODE_ENV, UPLOAD_DIR, CORS_ORIGIN, PROVIDER_PORTAL_URL |
Yes — dual URLs + dual secrets on AWS |
| Provider auth | PROVIDER_JWT_SECRET, TTLs, GOOGLE_OAUTH_* |
Yes |
| Veteran / OAuth | OAUTH_JWT_SECRET, OAUTH_ISSUER, VETERAN_SESSION_EXCHANGE_SECRET, ONSET_IDENTITY_ASSERTION_SECRET, LAST1_ID_OAUTH_* |
Yes |
| Crypto / webhooks | LAST1_TOKEN_ENCRYPTION_KEY, LAST1_WEBHOOK_SECRET, LASTVET_APP_SECRET, LAST1_PROOF_DRAINER_SECRET, LASTVET_INTERNAL_ADMIN_SECRET |
Yes (preserve encryption key if restoring rows) |
| Email / Stripe / alerts / waitlist | SENDGRID_*, STRIPE_*, SECURITY_ALERT_*, WAITLIST_* |
As features are live |
| Ops (Railway-specific) | RUN_MIGRATIONS_ON_BOOT, RAILWAY_ENVIRONMENT_NAME, RAILWAY_SERVICE_NAME |
Replace with AWS start policy |
Prod misconfig to fix on cutover¶
Production Railway still has NODE_ENV=development and RUN_MIGRATIONS_ON_BOOT=true. Boot migrate skip relies on RAILWAY_ENVIRONMENT_NAME=production.
On AWS:
NODE_ENV=production- Never auto-migrate prod from the task entrypoint
- Run migrate as a one-off task with
MIGRATION_DATABASE_URL
Empty-DB easy path still holds¶
Both DBs remain demo/sandbox scale. Prefer fresh RDS + full migrate (all 49 files) over dump/restore unless specific demo rows must survive. Document files on the Railway volume stay a separate small cutover item.
2. Railway-coupling audit¶
| Coupling | Severity | AWS change |
|---|---|---|
Dual DB URLs + lastvet_app role (now on prod + staging) |
High | Replicate on RDS: two Secrets Manager entries (master password, lastvet_app password); ECS uses DATABASE_APP_URL; migrate task uses MIGRATION_DATABASE_URL |
railway-start.sh uses RAILWAY_ENVIRONMENT_* / RAILWAY_SERVICE_NAME |
High | Generic entrypoint; migrate policy via APP_ENV / explicit flag |
SSL skip for *.railway.internal / *.proxy.rlwy.net |
High | RDS needs TLS (DATABASE_SSL or sslmode=require) |
UPLOAD_DIR on Railway volume /data |
High | EFS mount for parity; S3 later (code change) |
nixpacks + Railway auto-deploy on main |
Medium | ECR + GitHub Actions; use existing Dockerfile |
Healthcheck /health |
Low | ALB target group GET /health (already ok) |
Staging URL hardcoded (.railway.app) in provider/iOS |
Medium | Point staging clients at AWS staging hostname before teardown |
CI sync-sendgrid → Railway variables |
Medium | Rewrite to Secrets Manager |
Stripe / last1 / Google callbacks on api.last.vet |
Low if DNS kept | Keep hostname → ALB; no partner re-reg if cutover clean |
3. Proposed AWS target architecture¶
Recommendation summary¶
| Layer | Choice |
|---|---|
| IaC | Terraform |
| Compute | ECS Fargate behind ALB (not App Runner: closed to new accounts) |
| Data | RDS PostgreSQL 18 in private subnets |
| DB auth | Dual secrets: RDS master + lastvet_app (mirror Railway prod) |
| Files | EFS at /data |
| Secrets | Secrets Manager for all app secrets |
| Later (not now) | Bedrock/Meridian via VPC endpoint slot |
Terraform vs CDK¶
| Terraform | AWS CDK (TypeScript) | |
|---|---|---|
| Fit | Strong for VPC/RDS/ECS/ALB; plan/apply reviewable | Good if you want app-language constructs |
| HIPAA / audit | HCL diffs easy for external review; S3 state + lock | CloudFormation under the hood |
| Verdict | Choose Terraform | Defer unless CDK is already standard |
ECS Fargate vs App Runner¶
App Runner is unavailable for a new account. AWS closed App Runner to new customers (2026-04-30). Use ECS Fargate + explicit ALB for private RDS, EFS, and HIPAA-eligible networking.
Target topology¶
| Layer | Service | Config notes |
|---|---|---|
| Edge | Route 53 + ACM + ALB | api.last.vet → ALB; HTTPS; health GET /health |
| Compute | ECS Fargate (private) | Task secret: DATABASE_APP_URL (lastvet_app); 1–2 tasks to start |
| Data | RDS Postgres 18 (private) | Master user for migrate only; lastvet_app for runtime; encrypt; Multi-AZ when real PHI arrives |
| Migrate job | One-off ECS task / CI job | Secret: MIGRATION_DATABASE_URL (master); never from prod service boot |
| DB secrets | Secrets Manager (2) | lastvet/rds/master + lastvet/rds/lastvet_app (separate passwords, separate ARNs) |
| Files | EFS at /data |
UPLOAD_DIR=/data/lastvet-uploads |
| App secrets | Secrets Manager | Stripe, SendGrid, JWT keys, etc. |
| Network | VPC 2 AZ: public / private / data | NAT egress; SG: ALB→app→RDS/EFS only |
| Later (not now) | Bedrock + VPC endpoints | Meridian M1+; private invoke |
AWS secret layout (reference)¶
lastvet/rds/master → MIGRATION_DATABASE_URL (owner)
lastvet/rds/lastvet_app → DATABASE_APP_URL (runtime)
lastvet/app/<name> → other app secrets (JWT, Stripe, etc.)
Terraform wires ECS task definition to inject DATABASE_APP_URL from the lastvet_app secret ARN. Migrate CI/one-off task reads MIGRATION_DATABASE_URL from the master secret ARN.
4. Migration sequence + risks¶
Order of operations¶
| Step | Action | Owner lane |
|---|---|---|
| 0 | AWS account, MFA, billing alarms, accept BAA | You |
| 0b | LOCKED: dual URL + dual DB secrets on AWS (Railway prod is the reference) | Locked |
| 1 | Terraform: VPC, subnets, SGs, ECR, Secrets Manager (master + lastvet_app + app), IAM |
Infra |
| 2 | RDS 18 + EFS; create DB; populate both DB secrets | Infra |
| 3 | Ops prep PR: generic entrypoint, RDS TLS, Dockerfile deploy path (no Railway env keys) | API |
| 4 | CI: build → ECR → ECS (staging) | CI |
| 5 | Fresh migrate on staging RDS with master secret; provision lastvet_app password; wire DATABASE_APP_URL on ECS service |
API |
| 6 | Smoke /health + auth + RLS-sensitive document flows (confirm app role, not master) |
API |
| 7 | Optional: rsync Railway volume → EFS | Ops |
| 8 | Point staging clients at AWS staging hostname | Clients |
| 9 | Prod: fresh migrate + dual secrets; validate on temp hostname; DNS api.last.vet → ALB |
Cutover |
| 10 | Hold Railway 48–72h; then decommission | Cleanup |
Empty-DB easy path¶
Create empty RDS, run migrations 001–048 with master URL, provision lastvet_app password into its own secret, point ECS at DATABASE_APP_URL.
Re-seed demos only if needed. Preserve LAST1_TOKEN_ENCRYPTION_KEY only if restoring encrypted rows; otherwise rotate on the fresh DB.
What could break¶
| Risk | Likelihood | Mitigation |
|---|---|---|
API on RDS master bypasses RLS (046 theater) |
High if dual-URL skipped | Require DATABASE_APP_URL=lastvet_app before calling staging/prod done |
| Both URLs share one secret / password | Medium | Enforce two Secrets Manager entries; separate IAM read policies |
| TLS mismatch to RDS | High | DATABASE_SSL / sslmode=require; test from ECS task |
UPLOAD_DIR unset → process exits in production |
High | EFS mount + UPLOAD_DIR in task def |
| Boot auto-migrate on prod | Medium | Entrypoint never migrates when APP_ENV=production |
048 CONNECT grant on wrong DB name |
Medium | Grant on actual RDS database name after create |
Client hardcodes .railway.app |
Medium | Update CF Pages + iOS before Railway teardown |
| Encryption key mismatch on restore | Med if restoring | Prefer fresh migrate |
Rollback¶
Keep Railway prod API + Postgres + volume until AWS prod is verified.
Rollback = Route 53 api.last.vet back to Railway. Avoid writing new prod data on AWS during overlap, or accept those rows are lost on rollback. Hold Railway 48–72 hours after DNS cutover.
Effort estimate¶
| Calendar | About 1 focused week. Dual-URL is already proven on Railway prod; AWS work is replicating the pattern in Terraform + Secrets Manager. |
| Weekend-shaped slice | After account+BAA+TF skeleton: staging ECS+RDS+dual secrets+migrate green. Prod DNS remains a weekday cutover. |
| Out of scope | S3 upload rewrite, Bedrock/Meridian, RLS batch 2+, WAF beyond basics: after cutover. |
Stop line¶
Assessment refreshed after 046–048 on Railway prod. Dual URL + dual DB secrets remain locked.
Prod and staging are at parity. Railway prod is the reference implementation for AWS.
Remaining review decision: approve Terraform + ECS Fargate + RDS 18 + EFS + two DB secrets in Secrets Manager, then implement staging first.
Sources: live Railway DB queries Jul 11 2026 (prod + staging), migrations 046–048, src/config/environment.ts + database.ts, docs/api/TRACK3_PHI_RLS_PLAN.md.
Related docs:
docs/api/TRACK3_PHI_RLS_PLAN.mddocs/deployment/DEV_STAGING.mddocs/security/SECURITY_PENTEST_STATUS.md