Skip to main content

Database Decision

Decision date: 2026-03-15 Status: Approved — Aurora PostgreSQL Serverless v2


What is Aurora PostgreSQL?

Aurora is AWS's managed version of PostgreSQL.

Normally if you want PostgreSQL you have two options:

  • Self-hosted — you rent a server, install PostgreSQL yourself, handle backups, updates, crashes, scaling. A lot of ops work.
  • RDS PostgreSQL — AWS manages the server for you, but it's still a fixed-size machine you pay for 24/7.

Aurora is AWS's premium managed option. They took PostgreSQL, rewrote the storage layer to be more reliable and faster, and added automatic backups, auto-scaling, automatic failover, and zero maintenance overhead.

From a developer perspective — it's just PostgreSQL. Same SQL, same drivers, same ORM. You'd never know it's Aurora unless you looked at the AWS console.

Who manages itCostReliability
Self-hosted PostgreSQLYouCheapestYou're responsible
RDS PostgreSQLAWSMediumGood
Aurora PostgreSQLAWSSlightly moreBest

Serverless v2 means the cluster auto-scales based on actual load — measured in ACUs (Aurora Capacity Units). It scales down to near-zero when idle, so you pay essentially nothing during off-hours or in dev/staging environments. This is called scale-to-zero.


Context

Starlight needs a primary database to back all core domains: patients, appointments, medical records, billing, tasks, CRM, and accounting. The stack is TypeScript + AWS (SST/Lambda + API Gateway), mirroring the VibesWire baseline architecture.

The database choice must support:

  • Complex relational data (patients → appointments → billing → records)
  • Rich cross-domain queries ("all overdue invoices for active patients this month")
  • HIPAA-eligible infrastructure (real PHI will be stored)
  • Low cost at single-practice launch scale (Dr. Prajapati's practice, 17 patients)
  • Multi-tenant SaaS growth path (multiple practices in the future)

Options Evaluated

Option 1: DynamoDB

AWS-native NoSQL key-value store. Already used in VibesWire.

HIPAA✅ Supported via AWS BAA
Cost at launch✅ ~$0/month (permanent free tier)
Scale to zero✅ Yes
Query flexibility❌ Poor — access patterns must be defined upfront
Relational data❌ No joins
Schema enforcement❌ None

Why it doesn't fit Starlight: DynamoDB excels at simple, high-volume, predictable access patterns (e.g., VibesWire's news cache). Starlight's domain is deeply relational — a patient has appointments, appointments have notes and billing codes, billing links to insurance. DynamoDB requires pre-defining every query at table-design time, which is extremely difficult for a product that is still evolving. Getting it wrong means expensive table redesigns later.

Additionally: no joins means you either duplicate data everywhere (gets out of sync) or make multiple round-trips to the DB per request.


Option 2: DocumentDB

AWS-managed document database with MongoDB-compatible API.

HIPAA✅ Supported via AWS BAA
Cost at launch❌ ~$200+/month minimum (no scale-to-zero)
Scale to zero❌ No
Query flexibility⚠️ Medium
Relational data⚠️ Partial — $lookup aggregations, not true joins
Schema enforcement❌ None

Why it doesn't fit Starlight:

  1. No scale-to-zero — you pay $200+/month regardless of actual usage. Expensive while the practice has only 17 patients.
  2. Incomplete MongoDB compatibility — DocumentDB markets itself as "MongoDB-compatible" but many features don't work or behave differently. Developers hit subtle bugs and waste time debugging AWS-specific quirks.
  3. Document model wrong for our data — medical records, patients, appointments, billing are all related entities. No true joins means duplicating data everywhere or using slow $lookup aggregations.
  4. No schema enforcement — flexible schema sounds good early on but is dangerous for medical data. Nothing stops saving a patient record without a required field, or storing a date as a string in one record and a number in another.
  5. Hard to query ad-hoc — as Deepak and Dr. Prajapati discover they need new reports ("all kids due for vaccines in the next 30 days who haven't been billed"), SQL makes these trivial. DocumentDB aggregation pipelines for the same query are complex and hard to maintain.

Option 3: MongoDB Atlas

Fully managed MongoDB hosted on AWS (not AWS-native).

HIPAA✅ Supported on M10+ clusters via BAA
Cost at launch❌ ~$57-200/month minimum
Scale to zero❌ No
Query flexibility⚠️ Good, but still a document store
Relational data⚠️ Partial — same limitations as DocumentDB
Schema enforcement❌ None

Why it doesn't fit Starlight: External vendor alongside AWS — adds operational complexity and a separate billing relationship. More expensive than Aurora Serverless at low volume. And despite being more capable than DocumentDB, it's still a document store: the Starlight data model is fundamentally relational, and document DBs require denormalization hacks that accumulate technical debt as the product grows.


Option 4: Aurora PostgreSQL Serverless v2 ✅ Chosen

HIPAA✅ Supported via AWS BAA
Cost at launch✅ ~$0-5/month dev, ~$20-30/month prod
Scale to zero✅ Yes
Query flexibility✅ Full PostgreSQL — joins, CTEs, window functions, full-text search
Relational data✅ Native — foreign keys, cascades, constraints enforced at DB level
Schema enforcement✅ Strong — migrations enforce structure

Decision Summary Table

DynamoDBDocumentDBMongoDB AtlasAurora PostgreSQL
Relational queries⚠️⚠️
Scale to zero
Cost at launch✅ Free❌ $200+/mo❌ $57+/mo✅ ~$30-50/mo
HIPAA eligible
AWS-native
Schema enforcement

Architecture

Connection: RDS Data API

Lambda functions connect to Aurora via the RDS Data API — an HTTPS endpoint Aurora exposes. This avoids the need for a VPC + NAT Gateway setup.

Why this matters: Normally, Lambda needs to be inside the same VPC (Virtual Private Cloud — a private network on AWS) as Aurora to connect to it. But Lambdas inside a VPC lose internet access by default, requiring a NAT Gateway (~$30-40/month extra) to call external APIs.

With RDS Data API, Lambda talks to Aurora over HTTPS from outside the VPC — no VPC config, no NAT Gateway, full internet access preserved.

Lambda (outside VPC) → RDS Data API (HTTPS) → Aurora PostgreSQL

Trade-off: RDS Data API has slightly higher latency per query than a direct connection (HTTP overhead vs persistent TCP). Fine at our current scale. If performance becomes a concern as Starlight grows, migrating to a direct VPC connection is straightforward — especially if the DB layer is properly abstracted (see below).

ORM: Drizzle

Drizzle ORM — TypeScript-native, generates zero-overhead SQL, no runtime binary (unlike Prisma which ships a query engine). Schema defined in TypeScript, migrations generated by Drizzle Kit and checked into git.

Migrations run as a one-off SST deploy hook — never inside Lambda handlers.

SST Config

const db = new sst.aws.Aurora("StarlightDB", {
engine: "postgresql",
serverless: true,
dataApi: true, // enables RDS Data API — no VPC needed
})

Cost Estimate

EnvironmentConfigEstimated Monthly Cost
DevAurora Serverless v2, scales to 0~$0–5
StagingAurora Serverless v2, light usage~$10–20
ProductionAurora Serverless v2 + Data API~$20–30
Total~$30–55/month

No NAT Gateway needed = $30-40/month saved vs VPC approach.


Senior Engineering Note

Aurora PostgreSQL Serverless v2 + RDS Data API is a solid, production-grade choice for this stage. But one thing to be aware of:

RDS Data API has a known performance limitation — each query goes through an HTTP request/response cycle instead of a persistent TCP connection. For a dashboard making 10-15 queries per page load, this is noticeable at scale.

This is fine for:

  • Early stage with light traffic ✅
  • Dr. Prajapati's practice at current scale ✅
  • Dev and staging environments ✅

As Starlight grows, we may eventually migrate to a direct VPC connection. To make this painless, all DB access should go through a service/repository layer — never directly in route handlers:

// All DB access goes through this — easy to swap connection method later
import { db } from '@/lib/db'

// Route handler uses the service layer, not db directly
import { getPatientsForPractice } from '@/services/patients'

This way, changing from Data API to direct connection is a config change, not a rewrite.


HIPAA Notes

Before storing any real PHI:

  1. Sign AWS Business Associate Agreement (BAA) — free, done via AWS console under Account Settings
  2. Enable Aurora encryption at rest (default on new clusters)
  3. Enable CloudTrail for audit logging
  4. Restrict security groups — Aurora should only accept connections via Data API, never exposed to public internet

Schema Design

All tables include practice_id (multi-tenancy from day one), created_at, and updated_at.

Patients

patients (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
first_name text NOT NULL,
last_name text NOT NULL,
date_of_birth date NOT NULL,
gender text,
parent_guardian text,
email text,
phone text,
address jsonb,
insurance_provider text,
insurance_id text,
insurance_group text,
family_id uuid,
status text NOT NULL, -- active | prospect
pipeline_stage text, -- inquiry | meet-and-greet | scheduled-visit | ready-to-enroll
fee_override numeric,
referred_by text,
notes text,
is_active boolean DEFAULT true
)

Subscriptions & Billing Plans

subscription_plans (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
name text NOT NULL, -- Orion, Lyra, Sirius, Pegasus, Polaris
age_min integer,
age_max integer,
monthly_fee numeric NOT NULL
)

patient_subscriptions (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
patient_id uuid REFERENCES patients,
plan_id uuid REFERENCES subscription_plans,
started_at date NOT NULL,
ended_at date,
status text NOT NULL -- active | paused | cancelled
)

Appointments & Calendar

providers (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
name text NOT NULL,
specialty text,
email text,
color text
)

appointments (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
patient_id uuid REFERENCES patients,
provider_id uuid REFERENCES providers,
start_time timestamptz NOT NULL,
end_time timestamptz NOT NULL,
status text NOT NULL, -- scheduled | confirmed | completed | cancelled | no_show
appointment_type text NOT NULL, -- checkup | followup | sick_visit | vaccination | wellness
chief_complaint text,
notes text
)

Medical Records

medical_records (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
patient_id uuid REFERENCES patients,
provider_id uuid REFERENCES providers,
appointment_id uuid REFERENCES appointments,
record_type text NOT NULL, -- soap_note | lab_result | prescription | vaccination
content jsonb NOT NULL, -- flexible per record_type
recorded_at timestamptz NOT NULL
)

diagnoses (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
patient_id uuid REFERENCES patients,
medical_record_id uuid REFERENCES medical_records,
icd10_code text NOT NULL,
description text NOT NULL,
status text NOT NULL, -- active | resolved | chronic
diagnosed_at date NOT NULL
)

Tasks & CRM

tasks (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
patient_id uuid REFERENCES patients,
assigned_to uuid REFERENCES users,
title text NOT NULL,
description text,
due_date date,
status text NOT NULL, -- open | in_progress | done
priority text NOT NULL -- low | medium | high
)

contacts (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
type text NOT NULL, -- referral_doctor | insurance | vendor | other
name text NOT NULL,
email text,
phone text,
notes text
)

Invoices & Payments

invoices (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
patient_id uuid REFERENCES patients,
appointment_id uuid REFERENCES appointments,
status text NOT NULL, -- draft | sent | paid | overdue | void
due_date date,
paid_at timestamptz,
total_amount numeric NOT NULL
)

invoice_line_items (
id uuid PRIMARY KEY,
invoice_id uuid REFERENCES invoices,
cpt_code text,
description text NOT NULL,
quantity integer NOT NULL DEFAULT 1,
unit_price numeric NOT NULL,
amount numeric NOT NULL
)

payments (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
invoice_id uuid REFERENCES invoices,
patient_id uuid REFERENCES patients,
amount numeric NOT NULL,
payment_method text,
paid_at timestamptz NOT NULL,
reference_number text
)

Users & Auth

users (
id uuid PRIMARY KEY,
practice_id uuid NOT NULL,
email text NOT NULL UNIQUE,
role text NOT NULL, -- admin | provider | staff
external_auth_id text, -- Cognito/Auth0 sub claim
is_active boolean DEFAULT true
)

Key Indexes

CREATE INDEX idx_appointments_practice_start ON appointments(practice_id, start_time);
CREATE INDEX idx_appointments_patient ON appointments(patient_id);
CREATE INDEX idx_patients_name ON patients(practice_id, last_name, first_name);
CREATE INDEX idx_records_patient ON medical_records(patient_id, recorded_at DESC);
CREATE INDEX idx_invoices_status ON invoices(practice_id, status, due_date);
CREATE INDEX idx_tasks_assigned ON tasks(practice_id, assigned_to, status);