Run migrations once, before the code that needs them
Put the migration in the pipeline as its own step, not in app start-up where several instances can race. On Render, the pre-deploy command is meant for this: it runs after the build and before the new version goes live, and if it fails the deploy is aborted and the previous version keeps running. Use a separate production database. Make additive changes first (a nullable column), deploy the code, then tighten the constraint in a later migration.
# CI or pre-deploy step, with your tool's deploy command
npx prisma migrate deploy
Rehearse on a copy of production-shaped data
Restore a recent backup to a scratch database and run the migration there, timing it. Build indexes with CONCURRENTLY, which does not block writes but takes longer and cannot run inside a transaction block, so turn off your migration tool's automatic transaction for that file. If a concurrent build fails, it leaves an INVALID index (visible in psql with \d). Drop it and retry.
CREATE INDEX CONCURRENTLY events_tenant_idx ON events (tenant_id);
-- afterwards, in psql: \d events (look for INVALID)
Give schema changes a lock timeout
lock_timeout aborts a statement that waits too long for a lock, so a blocked ALTER TABLE fails visibly instead of waiting. Set it in the session that runs the migration. Also set server-side limits so a stuck transaction cannot hold a connection indefinitely.
SET lock_timeout = '5s';
ALTER ROLE app_user SET statement_timeout = '30s';
ALTER ROLE app_user SET idle_in_transaction_session_timeout = '10min';
Cap the pool and release the connection before slow calls
Create the pool once at module level and give it a maximum. Do not keep a client checked out while you call an external API. On Supabase, serverless code should use the shared pooler in transaction mode (turn off prepared statements in your driver), while migrations should use a direct connection. Pool size advice differs by platform. Vercel's guide warns against a maximum of 1 on Fluid compute and suggests a short idle timeout of around 5 seconds, so check your host's page.
const pool = new Pool({ max: 10, idleTimeoutMillis: 5000 });
const res = await fetch(slowApi); // slow call first, no connection held
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('INSERT ...', [await res.text()]);
await client.query('COMMIT');
} finally {
client.release();
}
Make every write that must happen once idempotent
Add a unique constraint on the natural key (client plus source id, or the provider's event id) and insert with ON CONFLICT DO NOTHING. With RETURNING, an empty result means the row already existed, so read it instead of inserting again. For calls to Stripe, send an Idempotency-Key header on POST requests. Stripe returns the saved response to a retry with the same key, and returns an error if the parameters differ.
CREATE UNIQUE INDEX imports_client_source_uq ON imports (client_id, source_id);
INSERT INTO imports (client_id, source_id, payload)
VALUES ($1, $2, $3)
ON CONFLICT (client_id, source_id) DO NOTHING
RETURNING id;
Fail the readiness check when the real store is unreachable
Remove any in-memory fallback in production, and make start-up fail if the database is not configured. Kubernetes separates the two probes: liveness reflects the app's own health, while readiness can also check each required back-end service, so traffic is not sent to an instance that can only return errors. Keep the dependency check out of the liveness probe, or a database outage will restart healthy instances.
app.get('/ready', async (_req, res) => {
try { await pool.query('select 1'); res.sendStatus(200); }
catch { res.sendStatus(503); }
});