Skip to content

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 001048 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 (001048) 49 (001048)
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.vet after 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_URLapi.last.vet
Volume (uploads) /dataUPLOAD_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() via migrationDb (owner URL)
  • Runtime pool prefers DATABASE_APP_URL over DATABASE_URL
  • Dockerfile exists; 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 (001048) 49 (001048)
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 001048 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 046048, src/config/environment.ts + database.ts, docs/api/TRACK3_PHI_RLS_PLAN.md.

Related docs:

  • docs/api/TRACK3_PHI_RLS_PLAN.md
  • docs/deployment/DEV_STAGING.md
  • docs/security/SECURITY_PENTEST_STATUS.md