The backend uses PostgreSQL via Prisma, optionally fronted by PgBouncer in
transaction-pooling mode (DATABASE_POOL_URL, see
backend/CONFIGURATION.md §"Database connection pooling"). DATABASE_URL
is the direct connection used for migrations and health checks; if
DATABASE_POOL_URL is set, Prisma uses it for all normal query traffic.
This runbook covers promoting a standby/replica to primary and repointing the application at it when the primary instance is unhealthy, unreachable, or needs to be taken down for maintenance. The exact promotion mechanics depend on how Postgres is hosted:
- Managed Postgres (RDS, Cloud SQL, Supabase, etc.) — use the provider's console/CLI to trigger failover to a standby; this runbook covers the application-side steps that follow.
- Self-managed streaming replication — promotion is a
pg_ctl promote(orpg_promote()) on the replica; coordinate with whoever owns the Postgres infrastructure if that isn't you.
If there is no standby/replica provisioned at all, this runbook doesn't apply — see the Database Backup Restore Procedure instead, and treat provisioning a standby as a follow-up.
GET /healthorGET /health/readyreports thedatabasecheck asunhealthy, or the app fails to start with a Prisma connection error (P1001: Can't reach database server).- API requests failing with 500s and logs showing
PrismaClientKnownRequestError/PrismaClientInitializationError. - Your infrastructure monitoring (RDS/Cloud SQL events,
pg_isready, or a synthetic query check) reports the primary as down or failing over. - Elevated connection errors from PgBouncer if
DATABASE_POOL_URLis configured (pgbouncer.inilogs show repeated connect failures to the backend).
- Confirm the primary is actually down before promoting anything —
promoting a standby while the old primary is still writable risks a
split-brain (two writable databases diverging). Check from the app host:
pg_isready -d "$DATABASE_URL" - Put the app in a degraded/maintenance stance if writes must stop
immediately. There is no built-in maintenance-mode flag; the fastest
safe option is to stop the backend process so it isn't writing against a
database that's about to change under it:
Skip this step if the primary is already fully unreachable (nothing is writing successfully anyway).
kill $(lsof -ti tcp:3001)
- Promote the standby using your hosting provider's mechanism:
- Managed Postgres: trigger failover from the provider console/CLI (e.g.
aws rds failover-db-clusterfor Aurora, or the Cloud SQL failover button). This usually also updates a stable endpoint DNS name for you — check whetherDATABASE_URLalready points at that stable endpoint rather than an instance-specific hostname, since that determines whether step 4 is even necessary. - Self-managed:
pg_ctl promote -D /path/to/dataon the replica (orSELECT pg_promote();if run viapsqlagainst Postgres 12+).
- Managed Postgres: trigger failover from the provider console/CLI (e.g.
- Confirm the new primary accepts writes:
psql "<new-primary-connection-string>" -c "SELECT pg_is_in_recovery();" # Expect: f (false) — a promoted primary is no longer "in recovery"
- Check the old primary's logs (or your hosting provider's event history) for what caused the failure: OOM kill, disk full, a bad migration, a hardware/AZ failure, or a manual maintenance action someone forgot to announce.
- If it was disk space: check for unbounded table growth — this project's
Transaction,RetryAttempt, andAuditLog-style tables are the most write-heavy; confirm any retention/archival jobs actually ran. - If it was connection exhaustion: check whether
DATABASE_POOL_URL(PgBouncer) was actually in front of traffic — direct-to-Postgres connections from many backend instances without pooling is a common cause. - If it was a bad migration: do not immediately re-run
prisma migrate deployagainst the new primary until you've confirmed which migrations already applied there (see Resolution step 2) — replaying a partially-applied migration can error out or, worse, silently diverge from what actually happened on the old primary before failover.
- Update connection strings in the backend environment to point at the
new primary (skip if your provider kept the same DNS endpoint and just
repointed it under the hood):
If you run PgBouncer yourself, also update its backend target in
# backend/.env (or your secrets manager) DATABASE_URL=postgresql://user:password@<new-primary-host>:5432/future_remittance DATABASE_POOL_URL=postgresql://user:password@<pgbouncer-host>:6432/future_remittance
pgbouncer.iniand reload it (pgbouncer -RorSIGHUP) — otherwise pooled connections keep going to the old (now-standby or dead) instance. - Verify migration state matches expectations before resuming traffic:
If the new primary is missing migrations the old primary had (replication lag at the moment of failure), apply them:
cd backend DATABASE_URL="<new-primary>" npx prisma migrate status
DATABASE_URL="<new-primary>" npx prisma migrate deploy - Restart the backend to pick up the new connection strings:
cd backend node src/server.js &
- Verify full connectivity:
curl -f http://localhost:3001/health/ready psql "$DATABASE_URL" -c "SELECT COUNT(*) FROM \"User\";"
- Provision a new standby replicating from the new primary as soon as practical — running without one leaves you exposed to a repeat incident with no failover target.
- If failover requires provider support (e.g. a managed-Postgres outage that their automated failover didn't resolve), open a support ticket with your hosting provider immediately in parallel with the steps above — don't wait for it to fail before escalating.
- If replication lag at the moment of failure means recent writes were lost (the new primary is missing transactions the old one had), escalate to engineering leadership before deciding on write-loss handling — this may need customer communication if it affected payment records.
- If no standby exists and the primary's disk/data is unrecoverable, escalate immediately and move to the Database Backup Restore Procedure.
- Confirm a standby is replicating from the new primary.
- Confirm
DATABASE_URL/DATABASE_POOL_URLare updated consistently across every environment and deployment target, not just the instance you patched live. - Reconcile any writes that may have been lost due to replication lag —
check
Transaction,RetryAttempt, andPaymentStreamtables for gaps around the failover timestamp. - If the root cause was disk space or connection exhaustion, file a follow-up for capacity planning or PgBouncer adoption.
- Write a post-mortem within 48 hours per
docs/runbook.md§5.5.